Let key-less PCs ask to enroll, and let an admin retry a trust-blocked PC

Found in the Windows VM re-test.

- A PC installed without the shared key got 401 from POST
  /api/endpoints/enroll while the shared key was allowed, so no request was
  recorded and no admin could approve it. Enroll now never refuses: a valid
  shared key only decides auto-issue on a clean match; no key or an invalid
  key creates or updates the one pending request for that hostname and
  serial. An approved request still issues only to its own nonce.
- A PC blocked with "server certificate not trusted by this PC" could never be
  queued again after trust was fixed, because the blocker comes from its last
  failed upgrade. Naming the PC in a wave now queues it when trust is its only
  blocker, and the response lists it under "retried"; "next n" still skips
  it. The Migration table lets such a PC be selected.
- counts.pending on GET /enrollment counts pending requests, including ones
  for unknown hostnames; counts.pendingcomputers keeps the per-computer figure.
This commit is contained in:
cproudlock
2026-09-15 15:01:57 -04:00
parent c2ffc591bb
commit 949398d495
9 changed files with 293 additions and 42 deletions

View File

@@ -599,10 +599,14 @@ credential plus its first 12 characters (`credentialprefix`) for display.
### 9.2 Enroll
`POST {baseurl}/api/endpoints/enroll`, authenticated with `X-API-Key` while
`plugin.endpoints.sharedkeyallowed` is true. With the shared key switched off
the route takes no auth, but then only returns a credential for a request an
admin has approved.
`POST {baseurl}/api/endpoints/enroll`. The route never refuses a call for its
authentication: a PC installed without the shared key still records a pending
request an admin can approve. `X-API-Key` only decides auto-issue: a valid
shared key while `plugin.endpoints.sharedkeyallowed` is true. No key, an invalid
key (logged by the server, never a 401 here) or the shared key switched off all
mean the call is unauthenticated, and it then only returns a credential for a
request an admin has approved for this hostname, serial and nonce. There is one
request row per hostname and serial (case-insensitive); repeated calls update it.
```jsonc
{
@@ -711,7 +715,12 @@ from the request body.
Read needs `endpoints.fleet`; writes need the admin role and are audited.
- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`)
- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`).
`counts.enrolled` and `counts.notenrolled` count computers by `state`
(`notenrolled` includes `revoked`); `counts.pendingcomputers` counts computers
in state `pending`; `counts.pending` is the number of pending enroll requests,
including requests that match no computer (unknown hostname) and requests from
PCs blocked from re-enrolling, so it equals the pending entries in `requests`.
- `POST /api/endpoints/enrollment/requests/<id>/approve` `{"assetid": n}`
- `POST /api/endpoints/enrollment/requests/<id>/reject`
- `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll). Optional
@@ -726,7 +735,10 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited.
record; a PC that was not blocked returns success unchanged.
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100)
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100).
Returns `{"waveid", "queued": [...], "skipped": [{"assetid", "reason"}], "retried": [...]}`.
`retried` lists queued PCs whose only blocker was
`server certificate not trusted by this PC` (see below).
- `GET /api/endpoints` rows carry `credentialstate` (`enrolled`,
`reenrollblocked`, `pending`, `notreporting`, `sharedkey`) and accept a
`credentialstate` filter. `GET /api/endpoints/<assetid>` carries a
@@ -737,5 +749,9 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited.
`presentedsha1thumbprint` and `matchesfile`, or `{"configured": false}`.
- Migration blockers include `server certificate not trusted by this PC`: the
PC's last upgrade download failed on the certificate chain (the server only
knows from the failure the PC reported). Trust the certificate
(`Install-ShopDBServerCertificate.ps1`) and queue it again.
knows from the failure the PC reported). It stays shown until a later upgrade
succeeds, so it is retryable: trust the certificate
(`Install-ShopDBServerCertificate.ps1`) and queue the PC again by its id in
`{"assetids": [...]}`. An explicit id is queued when this is the PC's only
blocker and is reported in `retried`; `{"next": n}` never picks such a PC, and
a PC with any other blocker as well is still skipped.

View File

@@ -28,7 +28,7 @@ import re
from collections import Counter
from datetime import datetime, timedelta, timezone
from flask import request
from flask import current_app, request
from flask_jwt_extended import current_user
from shopdb.api import (
@@ -204,16 +204,24 @@ def _text_field(payload, name, maximum, required):
def enroll_endpoint():
"""A PC asks for its own credential.
Shared-key authenticated while plugin.endpoints.sharedkeyallowed is on.
With it off the route takes no auth and only hands out a credential an
admin already approved for this exact hostname, serial and nonce.
Takes no auth. A PC installed without the shared key must still reach an
admin, so a missing or invalid key never refuses the call: it records or
updates the pending request for this hostname and serial. The shared key
only decides auto-enroll: a valid key, while plugin.endpoints.sharedkeyallowed
and plugin.endpoints.autoenroll are on, may issue on a clean match. Without
one, only a credential an admin already approved for this exact hostname,
serial and nonce is handed out.
"""
sharedkeyauthenticated = False
if agentauth.shared_key_allowed():
error = collector_authorized('endpoints')
if error is not None:
return error
presented = bool(request.headers.get('X-API-Key')
or request.headers.get('Authorization'))
if presented and agentauth.shared_key_allowed():
if collector_authorized('endpoints') is None:
sharedkeyauthenticated = True
else:
current_app.logger.warning(
'endpoint enroll from %s presented an invalid API key; '
'treated as unauthenticated', client_ip(request))
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
@@ -505,11 +513,14 @@ def get_enrollment():
if blocked else None),
})
# Computer counts by state, except pending: that is every pending request,
# including those naming no known computer, so it matches the request list.
counts = Counter(row['state'] for row in rows)
summary = {
'enrolled': counts['enrolled'],
'notenrolled': counts['notenrolled'] + counts['revoked'],
'pending': counts['pending'],
'pendingcomputers': counts['pending'],
'pending': len(pending),
}
state = (request.args.get('state') or '').strip().lower()
@@ -771,6 +782,16 @@ def _is_tls_trust_failure(command):
return any(marker in text for marker in _TLS_TRUST_FAILURES)
def is_trust_retryable(blockers):
"""True when nothing blocks the PC but, at most, the certificate trust failure.
The server only learns about trust from the PC's last failed download, so
that blocker cannot clear by itself. An admin who fixed trust may queue the
PC by id; a wave picking the next ready PCs never does.
"""
return all(blocker == BLOCKER_CERT_UNTRUSTED for blocker in blockers)
def compute_readiness(assetids=None):
"""Migration state for every active computer (or just assetids).
@@ -847,8 +868,8 @@ def compute_readiness(assetids=None):
blockers.append(BLOCKER_CERT_UNTRUSTED)
if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION):
blockers.append(BLOCKER_AWAITING_ENROLLMENT)
if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR(
assetid=assetid):
if (is_trust_retryable(blockers)
and 'run_script' not in ALLOWED_ACTIONS_FOR(assetid=assetid)):
blockers.append(BLOCKER_POLICY)
status = 'blocked' if blockers else 'ready'
result.append({
@@ -955,7 +976,9 @@ def create_migration_wave():
Body {"assetids": [...]} or {"next": n}. Readiness is re-checked per PC; the
agent is packaged once for the whole wave, then each PC is queued through
the same path as POST /<asset>/agentupgrade.
the same path as POST /<asset>/agentupgrade. An explicit id whose only
blocker is the certificate trust failure is queued too and listed in
retried: the admin is saying trust was fixed.
"""
from .routes import prepare_agent_upgrade, queue_prepared_agent_upgrade
@@ -987,10 +1010,15 @@ def create_migration_wave():
readiness = {row['assetid']: row for row in compute_readiness(candidates)}
skipped = []
ready = []
retrying = set()
for assetid in candidates:
row = readiness.get(assetid)
if row is None:
skipped.append({'assetid': assetid, 'reason': 'not a computer'})
elif (requested is not None and row['state'] == 'blocked'
and row['blockers'] and is_trust_retryable(row['blockers'])):
retrying.add(assetid)
ready.append(assetid)
elif row['state'] != 'ready':
reason = ', '.join(row['blockers']) if row['blockers'] else row['state']
skipped.append({'assetid': assetid, 'reason': reason})
@@ -1027,8 +1055,10 @@ def create_migration_wave():
.format(len(queued), wave.assetids))
db.session.commit()
retried = [assetid for assetid in queued if assetid in retrying]
return success_response(
{'waveid': waveid, 'queued': queued, 'skipped': skipped},
{'waveid': waveid, 'queued': queued, 'skipped': skipped,
'retried': retried},
message='{} PC(s) queued'.format(len(queued)),
http_code=201 if queued else 200)

View File

@@ -158,7 +158,12 @@ nothing. A non-CA leaf in Root trusts only this one server.
If a wave reaches a PC that does not trust the certificate, the download fails
and the Migration tab marks the PC **Blocked** with `server certificate not
trusted by this PC`. Fix trust with the entry above, then queue the PC again.
trusted by this PC`. The server only knows about trust from that failure, so
the blocker stays until the PC is queued again. Fix trust with the entry above,
then in the Migration tab tick the PC (a PC whose only blocker is this one keeps
a checkbox and shows "Retry after fixing trust") and click **Upgrade selected**.
**Upgrade next 25** never picks it, and a PC with any other blocker as well
stays unselectable until that blocker is fixed.
## Phase 2: re-stamp the share's agent entries
@@ -233,7 +238,7 @@ Repeat until **Ready** is 0.
in store`: upload 1.20. `agent too old for remote upgrade`: move it via the
share (targeted agent entry as in phase 2). `server certificate not trusted
by this PC`: add the PC to the certificate entry (phase 1), wait a cycle,
queue it again. `reenroll blocked`: the PC was rolled back on purpose; clear
then tick it and **Upgrade selected** (phase 1 explains the retry). `reenroll blocked`: the PC was rolled back on purpose; clear
it with allow-reenroll (see Rollback) only when moving it forward again.
- **Not reporting:** the PC has no working agent. It can only move via the
share channel: add its hostname to a targeted 1.20 agent entry. It shows as
@@ -244,9 +249,13 @@ Repeat until **Ready** is 0.
matched computer, search to choose another. The PC collects its credential on
its next retry (within 15 minutes). **Reject** anything you do not recognise.
- **Newly imaged PCs:** imaging installs 1.20 with the shared key, and the agent
auto-enrolls on first start. After the shared key is turned off (phase 6) a
new PC cannot auto-enroll: its request lands in Pending and an admin approves
it in the UI. That is the steady-state path for new PCs.
auto-enrolls on first start. A new PC can also be installed WITHOUT the
shared key at any time, before or after cutover (the generated installer's
"Include shared key" option off): the enroll call needs no key, so its request
lands in Pending and it waits there until an admin approves it in the UI. An
invalid key behaves the same (the server logs it). After the shared key is
turned off (phase 6) that is the only path, and the steady-state path for new
PCs.
## Phase 6: cutover

View File

@@ -663,7 +663,7 @@ async function loadEnrollment() {
const counts = data.counts || {}
const enrolled = counts.enrolled || 0
// pending PCs asked but hold no credential yet: still on the shared key.
const notenrolled = (counts.notenrolled || 0) + (counts.pending || 0)
const notenrolled = (counts.notenrolled || 0) + (counts.pendingcomputers || 0)
enrollment.value = {
enrolled,
notenrolled,

View File

@@ -125,7 +125,7 @@
<tr v-for="row in migrationRows" :key="row.assetid" class="migration-row">
<td v-if="isadmin" class="select-cell">
<input
v-if="row.state === 'ready'"
v-if="isSelectable(row)"
v-model="selected"
type="checkbox"
:value="row.assetid"
@@ -135,7 +135,12 @@
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
<td>{{ row.agentversion || '-' }}</td>
<td>{{ formatDate(row.lastreportedat) }}</td>
<td class="blockers">{{ (row.blockers || []).join(', ') || '-' }}</td>
<td class="blockers">
{{ (row.blockers || []).join(', ') || '-' }}
<span v-if="isadmin && isTrustRetryable(row)" class="retry-hint">
Retry after fixing trust
</span>
</td>
</tr>
</tbody>
</table>
@@ -516,6 +521,20 @@ const enrolledPercent = computed(() => {
})
const readyRows = computed(() => migrationRows.value.filter(row => row.state === 'ready'))
// The server cannot see a PC's trust store, so this blocker only clears when
// an admin queues the PC again after fixing trust. Next N never picks it.
const TRUST_BLOCKER = 'server certificate not trusted by this PC'
function isTrustRetryable(row) {
const blockers = row.blockers || []
return row.state === 'blocked' && blockers.length > 0
&& blockers.every(blocker => blocker === TRUST_BLOCKER)
}
function isSelectable(row) {
return row.state === 'ready' || isTrustRetryable(row)
}
const allReadySelected = computed(() =>
readyRows.value.length > 0 && readyRows.value.every(row => selected.value.includes(row.assetid)))
@@ -541,9 +560,9 @@ async function loadMigration({ quiet = false } = {}) {
migrationRows.value = data.computers || []
waves.value = data.waves || []
migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1
// Drop selections that are no longer ready (queued by a wave, went offline).
const readyIds = new Set(readyRows.value.map(row => row.assetid))
selected.value = selected.value.filter(id => readyIds.has(id))
// Drop selections that are no longer selectable (queued by a wave, went offline).
const selectableIds = new Set(migrationRows.value.filter(isSelectable).map(row => row.assetid))
selected.value = selected.value.filter(id => selectableIds.has(id))
migrationError.value = ''
} catch (error) {
migrationError.value = apiError(error, 'Could not load migration status')
@@ -587,6 +606,11 @@ async function startWave(body, count) {
const queued = (data.queued || []).length
const skipped = data.skipped || []
if (queued) toast.success(`Wave ${data.waveid}: upgrade queued for ${queued} PC(s)`)
const retried = data.retried || []
if (retried.length) {
toast.info(`${retried.length} queued again despite the certificate trust blocker: `
+ retried.slice(0, 5).join(', '))
}
if (skipped.length) {
toast.info(`${skipped.length} skipped: `
+ skipped.slice(0, 5).map(item => `${item.assetid} (${item.reason})`).join(', '))
@@ -1045,6 +1069,12 @@ onBeforeUnmount(() => {
[data-theme="dark"] .state-chip.pending,
[data-theme="dark"] .state-chip.upgrading { color: var(--warning); }
.retry-hint {
display: block;
font-size: 0.8em;
color: var(--text-light);
}
.blockers {
font-size: 0.85rem;
color: var(--text-light);

View File

@@ -23,7 +23,7 @@ function enrollmentPayload(settings = { sharedkeyallowed: true, autoenroll: true
data: {
data: {
settings,
counts: { enrolled: 30, notenrolled: 8, pending: 2 },
counts: { enrolled: 30, notenrolled: 8, pendingcomputers: 2, pending: 3 },
computers: [],
requests: [
{ requestid: 1, status: 'pending', reason: 'serial mismatch' },

View File

@@ -35,7 +35,11 @@ function migrationPayload() {
computers: [
{ assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] },
{ assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked',
blockers: ['offline', 'no serial on record'] }
blockers: ['offline', 'no serial on record'] },
{ assetid: 13, hostname: 'PC-TRUST', agentversion: '1.19.2', lastreportedat: null, state: 'blocked',
blockers: ['server certificate not trusted by this PC'] },
{ assetid: 14, hostname: 'PC-TRUST-OFFLINE', agentversion: '1.19.2', lastreportedat: null, state: 'blocked',
blockers: ['offline', 'server certificate not trusted by this PC'] }
],
waves: [
{ waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false }
@@ -51,7 +55,7 @@ function enrollmentPayload() {
data: {
data: {
settings: { sharedkeyallowed: true, autoenroll: true },
counts: { enrolled: 40, notenrolled: 58, pending: 2 },
counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 },
computers: [
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto',
@@ -120,6 +124,29 @@ describe('EnrollmentSettings', () => {
expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false)
})
it('offers a trust-only blocked PC for retry, not one with another blocker too', async () => {
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE'))
const mixed = rows.find(r => r.text().includes('PC-TRUST-OFFLINE'))
expect(trust.find('input[type="checkbox"]').exists()).toBe(true)
expect(trust.find('.retry-hint').text()).toBe('Retry after fixing trust')
expect(mixed.find('input[type="checkbox"]').exists()).toBe(false)
expect(mixed.find('.retry-hint').exists()).toBe(false)
})
it('includes a trust-blocked PC in Upgrade selected', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
await rows[0].find('input[type="checkbox"]').setValue(true)
const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE'))
await trust.find('input[type="checkbox"]').setValue(true)
await wrapper.find('.upgrade-selected').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11, 13] })
})
it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)

View File

@@ -130,11 +130,62 @@ def test_enroll_validates_the_nonce(client, db, app, keyheaders):
assert response.status_code == 400, nonce
def test_enroll_requires_the_shared_key_while_it_is_allowed(client, db, app,
keyheaders):
_computer(db, 'BAY-01')
response = client.post(ENROLL_URL, json=_enroll_body())
assert response.status_code in (401, 500)
@pytest.mark.parametrize('headers', [
{},
{'X-API-Key': 'not-the-key'},
])
def test_enroll_without_a_valid_key_records_a_pending_request(
client, db, app, keyheaders, auth_headers, caplog, headers):
from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest
assetid = _computer(db, 'BAY-01')
response = _enroll(client, headers)
assert response.status_code == 202, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['status'] == 'pending'
assert data['reason'] == 'awaiting approval'
assert EndpointCredential.query.filter_by(assetid=assetid).first() is None
assert ('invalid API key' in caplog.text) == bool(headers)
# Retrying updates the same row; it never piles up rows.
assert _enroll(client, headers).status_code == 202
assert EndpointEnrollRequest.query.count() == 1
listing = client.get('/api/endpoints/enrollment',
headers=auth_headers).get_json()['data']
assert [(r['requestid'], r['status']) for r in listing['requests']] == [
(data['requestid'], 'pending')]
assert listing['counts']['pending'] == 1
def test_enroll_with_no_key_for_an_unknown_hostname_is_pending(
client, db, app, keyheaders):
response = _enroll(client, {}, hostname='NEW-PC')
assert response.status_code == 202, response.get_data(as_text=True)
assert response.get_json()['data']['reason'] == 'unknown hostname'
def test_approved_request_issues_to_its_nonce_without_a_key(
client, db, app, keyheaders, auth_headers):
from plugins.endpoints.models import EndpointCredential
assetid = _computer(db, 'BAY-01')
pending = _enroll(client, {})
requestid = pending.get_json()['data']['requestid']
approve = client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
assert approve.status_code == 200, approve.get_data(as_text=True)
wrong = _enroll(client, {}, nonce=OTHER_NONCE)
assert wrong.status_code == 403
assert _code(wrong) == 'ENROLL_NONCE_MISMATCH'
issued = _enroll(client, {})
assert issued.status_code == 201, issued.get_data(as_text=True)
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
.enrolledvia == 'approved'
def test_a_second_nonce_on_a_pending_request_stays_pending(client, db, app,
@@ -638,7 +689,8 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
body = response.get_json()
data = body['data']
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True}
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1, 'pending': 1}
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1,
'pendingcomputers': 1, 'pending': 1}
states = {row['hostname']: row['state'] for row in data['computers']}
assert states == {'BAY-01': 'enrolled', 'BAY-02': 'notenrolled',
'BAY-03': 'pending'}
@@ -663,6 +715,23 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
assert bad.status_code == 400
def test_enrollment_pending_count_includes_unmatched_requests(
client, db, app, keyheaders, auth_headers):
_computer(db, 'BAY-01', serialnumber=None)
_computer(db, 'BAY-02', serialnumber='SER-2')
_enroll(client, keyheaders, hostname='BAY-01', serialnumber='X')
_enroll(client, {}, hostname='NOT-IN-SHOPDB', serialnumber='Y')
data = client.get('/api/endpoints/enrollment',
headers=auth_headers).get_json()['data']
pendingrequests = [r for r in data['requests'] if r['status'] == 'pending']
assert len(pendingrequests) == 2
assert data['counts'] == {'enrolled': 0, 'notenrolled': 1,
'pendingcomputers': 1, 'pending': 2}
states = [row['state'] for row in data['computers']]
assert states.count('pending') == data['counts']['pendingcomputers']
def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders,
auth_headers):
assetid = _computer(db, 'BAY-01')

View File

@@ -372,6 +372,76 @@ def test_a_later_success_clears_the_tls_blocker(client, db, app, auth_headers):
assert _by_host(_rows(client, auth_headers))['PC-FIXED']['state'] == 'ready'
def test_a_trust_blocked_pc_is_retried_only_by_explicit_id(client, db, app,
auth_headers):
from plugins.endpoints.models import EndpointCommand, EndpointMigrationWave
_publish_agent()
trustonly = _pc(db, 'PC-TRUST')
alsooffline = _pc(db, 'PC-TRUST-OFFLINE', reportedago=timedelta(days=2))
agentregistry.register_current_agent_package()
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
for assetid in (trustonly, alsooffline):
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED,
resultmessage='UntrustedRoot', completedat=_now()))
db.session.commit()
rows = _by_host(_rows(client, auth_headers))
assert rows['PC-TRUST']['blockers'] == [
'server certificate not trusted by this PC']
assert rows['PC-TRUST-OFFLINE']['blockers'] == [
'offline', 'server certificate not trusted by this PC']
nextwave = client.post(WAVE_URL, json={'next': 10}, headers=auth_headers)
assert nextwave.status_code == 200, nextwave.get_data(as_text=True)
assert nextwave.get_json()['data']['queued'] == []
assert nextwave.get_json()['data']['retried'] == []
assert EndpointMigrationWave.query.count() == 0
response = client.post(WAVE_URL,
json={'assetids': [trustonly, alsooffline]},
headers=auth_headers)
assert response.status_code == 201, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['queued'] == [trustonly]
assert data['retried'] == [trustonly]
assert data['skipped'] == [{
'assetid': alsooffline,
'reason': 'offline, server certificate not trusted by this PC'}]
assert EndpointMigrationWave.query.one().assetidlist == [trustonly]
assert _by_host(_rows(client, auth_headers))['PC-TRUST']['state'] \
== 'upgrading'
def test_a_trust_blocked_pc_still_needs_run_script_policy(client, db, app,
auth_headers,
monkeypatch):
from plugins.endpoints.api import enrollment
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _pc(db, 'PC-TRUST')
agentregistry.register_current_agent_package()
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED,
resultmessage='UntrustedRoot', completedat=_now()))
db.session.commit()
monkeypatch.setattr(enrollment, 'ALLOWED_ACTIONS_FOR',
lambda assetid=None: set())
data = client.post(WAVE_URL, json={'assetids': [assetid]},
headers=auth_headers).get_json()['data']
assert data['queued'] == []
assert data['skipped'] == [{
'assetid': assetid,
'reason': 'server certificate not trusted by this PC, '
'run_script not permitted by policy'}]
def test_wave_failed_forgets_a_pc_a_later_wave_upgraded(client, db, app,
auth_headers):
from plugins.endpoints.models import EndpointCommand, EndpointState