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 ### 9.2 Enroll
`POST {baseurl}/api/endpoints/enroll`, authenticated with `X-API-Key` while `POST {baseurl}/api/endpoints/enroll`. The route never refuses a call for its
`plugin.endpoints.sharedkeyallowed` is true. With the shared key switched off authentication: a PC installed without the shared key still records a pending
the route takes no auth, but then only returns a credential for a request an request an admin can approve. `X-API-Key` only decides auto-issue: a valid
admin has approved. 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 ```jsonc
{ {
@@ -711,7 +715,12 @@ from the request body.
Read needs `endpoints.fleet`; writes need the admin role and are audited. 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>/approve` `{"assetid": n}`
- `POST /api/endpoints/enrollment/requests/<id>/reject` - `POST /api/endpoints/enrollment/requests/<id>/reject`
- `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll). Optional - `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. record; a PC that was not blocked returns success unchanged.
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}` - `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`) - `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`, - `GET /api/endpoints` rows carry `credentialstate` (`enrolled`,
`reenrollblocked`, `pending`, `notreporting`, `sharedkey`) and accept a `reenrollblocked`, `pending`, `notreporting`, `sharedkey`) and accept a
`credentialstate` filter. `GET /api/endpoints/<assetid>` carries 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}`. `presentedsha1thumbprint` and `matchesfile`, or `{"configured": false}`.
- Migration blockers include `server certificate not trusted by this PC`: the - Migration blockers include `server certificate not trusted by this PC`: the
PC's last upgrade download failed on the certificate chain (the server only PC's last upgrade download failed on the certificate chain (the server only
knows from the failure the PC reported). Trust the certificate knows from the failure the PC reported). It stays shown until a later upgrade
(`Install-ShopDBServerCertificate.ps1`) and queue it again. 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 collections import Counter
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from flask import request from flask import current_app, request
from flask_jwt_extended import current_user from flask_jwt_extended import current_user
from shopdb.api import ( from shopdb.api import (
@@ -204,16 +204,24 @@ def _text_field(payload, name, maximum, required):
def enroll_endpoint(): def enroll_endpoint():
"""A PC asks for its own credential. """A PC asks for its own credential.
Shared-key authenticated while plugin.endpoints.sharedkeyallowed is on. Takes no auth. A PC installed without the shared key must still reach an
With it off the route takes no auth and only hands out a credential an admin, so a missing or invalid key never refuses the call: it records or
admin already approved for this exact hostname, serial and nonce. 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 sharedkeyauthenticated = False
if agentauth.shared_key_allowed(): presented = bool(request.headers.get('X-API-Key')
error = collector_authorized('endpoints') or request.headers.get('Authorization'))
if error is not None: if presented and agentauth.shared_key_allowed():
return error if collector_authorized('endpoints') is None:
sharedkeyauthenticated = True 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) payload = request.get_json(silent=True)
if not isinstance(payload, dict): if not isinstance(payload, dict):
@@ -505,11 +513,14 @@ def get_enrollment():
if blocked else None), 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) counts = Counter(row['state'] for row in rows)
summary = { summary = {
'enrolled': counts['enrolled'], 'enrolled': counts['enrolled'],
'notenrolled': counts['notenrolled'] + counts['revoked'], 'notenrolled': counts['notenrolled'] + counts['revoked'],
'pending': counts['pending'], 'pendingcomputers': counts['pending'],
'pending': len(pending),
} }
state = (request.args.get('state') or '').strip().lower() 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) 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): def compute_readiness(assetids=None):
"""Migration state for every active computer (or just assetids). """Migration state for every active computer (or just assetids).
@@ -847,8 +868,8 @@ def compute_readiness(assetids=None):
blockers.append(BLOCKER_CERT_UNTRUSTED) blockers.append(BLOCKER_CERT_UNTRUSTED)
if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION): if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION):
blockers.append(BLOCKER_AWAITING_ENROLLMENT) blockers.append(BLOCKER_AWAITING_ENROLLMENT)
if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR( if (is_trust_retryable(blockers)
assetid=assetid): and 'run_script' not in ALLOWED_ACTIONS_FOR(assetid=assetid)):
blockers.append(BLOCKER_POLICY) blockers.append(BLOCKER_POLICY)
status = 'blocked' if blockers else 'ready' status = 'blocked' if blockers else 'ready'
result.append({ result.append({
@@ -955,7 +976,9 @@ def create_migration_wave():
Body {"assetids": [...]} or {"next": n}. Readiness is re-checked per PC; the 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 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 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)} readiness = {row['assetid']: row for row in compute_readiness(candidates)}
skipped = [] skipped = []
ready = [] ready = []
retrying = set()
for assetid in candidates: for assetid in candidates:
row = readiness.get(assetid) row = readiness.get(assetid)
if row is None: if row is None:
skipped.append({'assetid': assetid, 'reason': 'not a computer'}) 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': elif row['state'] != 'ready':
reason = ', '.join(row['blockers']) if row['blockers'] else row['state'] reason = ', '.join(row['blockers']) if row['blockers'] else row['state']
skipped.append({'assetid': assetid, 'reason': reason}) skipped.append({'assetid': assetid, 'reason': reason})
@@ -1027,8 +1055,10 @@ def create_migration_wave():
.format(len(queued), wave.assetids)) .format(len(queued), wave.assetids))
db.session.commit() db.session.commit()
retried = [assetid for assetid in queued if assetid in retrying]
return success_response( return success_response(
{'waveid': waveid, 'queued': queued, 'skipped': skipped}, {'waveid': waveid, 'queued': queued, 'skipped': skipped,
'retried': retried},
message='{} PC(s) queued'.format(len(queued)), message='{} PC(s) queued'.format(len(queued)),
http_code=201 if queued else 200) 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 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 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 ## 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 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 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, 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. 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 - **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 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 matched computer, search to choose another. The PC collects its credential on
its next retry (within 15 minutes). **Reject** anything you do not recognise. 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 - **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 auto-enrolls on first start. A new PC can also be installed WITHOUT the
new PC cannot auto-enroll: its request lands in Pending and an admin approves shared key at any time, before or after cutover (the generated installer's
it in the UI. That is the steady-state path for new PCs. "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 ## Phase 6: cutover

View File

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

View File

@@ -125,7 +125,7 @@
<tr v-for="row in migrationRows" :key="row.assetid" class="migration-row"> <tr v-for="row in migrationRows" :key="row.assetid" class="migration-row">
<td v-if="isadmin" class="select-cell"> <td v-if="isadmin" class="select-cell">
<input <input
v-if="row.state === 'ready'" v-if="isSelectable(row)"
v-model="selected" v-model="selected"
type="checkbox" type="checkbox"
:value="row.assetid" :value="row.assetid"
@@ -135,7 +135,12 @@
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td> <td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
<td>{{ row.agentversion || '-' }}</td> <td>{{ row.agentversion || '-' }}</td>
<td>{{ formatDate(row.lastreportedat) }}</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> </tr>
</tbody> </tbody>
</table> </table>
@@ -516,6 +521,20 @@ const enrolledPercent = computed(() => {
}) })
const readyRows = computed(() => migrationRows.value.filter(row => row.state === 'ready')) 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(() => const allReadySelected = computed(() =>
readyRows.value.length > 0 && readyRows.value.every(row => selected.value.includes(row.assetid))) 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 || [] migrationRows.value = data.computers || []
waves.value = data.waves || [] waves.value = data.waves || []
migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1 migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1
// Drop selections that are no longer ready (queued by a wave, went offline). // Drop selections that are no longer selectable (queued by a wave, went offline).
const readyIds = new Set(readyRows.value.map(row => row.assetid)) const selectableIds = new Set(migrationRows.value.filter(isSelectable).map(row => row.assetid))
selected.value = selected.value.filter(id => readyIds.has(id)) selected.value = selected.value.filter(id => selectableIds.has(id))
migrationError.value = '' migrationError.value = ''
} catch (error) { } catch (error) {
migrationError.value = apiError(error, 'Could not load migration status') migrationError.value = apiError(error, 'Could not load migration status')
@@ -587,6 +606,11 @@ async function startWave(body, count) {
const queued = (data.queued || []).length const queued = (data.queued || []).length
const skipped = data.skipped || [] const skipped = data.skipped || []
if (queued) toast.success(`Wave ${data.waveid}: upgrade queued for ${queued} PC(s)`) 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) { if (skipped.length) {
toast.info(`${skipped.length} skipped: ` toast.info(`${skipped.length} skipped: `
+ skipped.slice(0, 5).map(item => `${item.assetid} (${item.reason})`).join(', ')) + 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.pending,
[data-theme="dark"] .state-chip.upgrading { color: var(--warning); } [data-theme="dark"] .state-chip.upgrading { color: var(--warning); }
.retry-hint {
display: block;
font-size: 0.8em;
color: var(--text-light);
}
.blockers { .blockers {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-light); color: var(--text-light);

View File

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

View File

@@ -35,7 +35,11 @@ function migrationPayload() {
computers: [ computers: [
{ assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] }, { 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', { 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: [ waves: [
{ waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false } { waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false }
@@ -51,7 +55,7 @@ function enrollmentPayload() {
data: { data: {
data: { data: {
settings: { sharedkeyallowed: true, autoenroll: true }, settings: { sharedkeyallowed: true, autoenroll: true },
counts: { enrolled: 40, notenrolled: 58, pending: 2 }, counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 },
computers: [ computers: [
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled', { assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto', 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) 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 () => { it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => {
const confirmSpy = vi.fn(() => true) const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy) 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 assert response.status_code == 400, nonce
def test_enroll_requires_the_shared_key_while_it_is_allowed(client, db, app, @pytest.mark.parametrize('headers', [
keyheaders): {},
_computer(db, 'BAY-01') {'X-API-Key': 'not-the-key'},
response = client.post(ENROLL_URL, json=_enroll_body()) ])
assert response.status_code in (401, 500) 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, 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() body = response.get_json()
data = body['data'] data = body['data']
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True} 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']} states = {row['hostname']: row['state'] for row in data['computers']}
assert states == {'BAY-01': 'enrolled', 'BAY-02': 'notenrolled', assert states == {'BAY-01': 'enrolled', 'BAY-02': 'notenrolled',
'BAY-03': 'pending'} 'BAY-03': 'pending'}
@@ -663,6 +715,23 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
assert bad.status_code == 400 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, def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders,
auth_headers): auth_headers):
assetid = _computer(db, 'BAY-01') 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' 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, def test_wave_failed_forgets_a_pc_a_later_wave_upgraded(client, db, app,
auth_headers): auth_headers):
from plugins.endpoints.models import EndpointCommand, EndpointState from plugins.endpoints.models import EndpointCommand, EndpointState