From 1c2ba677c3c6b9c3726053ccabc664bf6b900ba2 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 15 Sep 2026 15:01:57 -0400 Subject: [PATCH] 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. --- plugins/endpoints/AGENT-CONTRACT.md | 32 ++++++-- plugins/endpoints/api/enrollment.py | 58 +++++++++---- .../docs/PER-PC-CREDENTIALS-ROLLOUT.md | 19 +++-- .../frontend/views/AgentKeySettings.vue | 2 +- .../frontend/views/EnrollmentSettings.vue | 40 +++++++-- .../frontend/views/agentKeySettings.spec.js | 2 +- .../frontend/views/enrollmentSettings.spec.js | 31 ++++++- .../test_endpoints_credentials.py | 81 +++++++++++++++++-- .../test_endpoints_enrollment_migration.py | 70 ++++++++++++++++ 9 files changed, 293 insertions(+), 42 deletions(-) diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md index cd2dcbad..a25d4b07 100644 --- a/plugins/endpoints/AGENT-CONTRACT.md +++ b/plugins/endpoints/AGENT-CONTRACT.md @@ -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//approve` `{"assetid": n}` - `POST /api/endpoints/enrollment/requests//reject` - `POST /api/endpoints/enrollment/assets//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/` 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. diff --git a/plugins/endpoints/api/enrollment.py b/plugins/endpoints/api/enrollment.py index 517aedeb..5552a950 100644 --- a/plugins/endpoints/api/enrollment.py +++ b/plugins/endpoints/api/enrollment.py @@ -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 - sharedkeyauthenticated = True + 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 //agentupgrade. + the same path as POST //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) diff --git a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md index 559928f0..87e0a4e3 100644 --- a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md +++ b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md @@ -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 diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue index 3a0581ac..eedcf221 100644 --- a/plugins/endpoints/frontend/views/AgentKeySettings.vue +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -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, diff --git a/plugins/endpoints/frontend/views/EnrollmentSettings.vue b/plugins/endpoints/frontend/views/EnrollmentSettings.vue index 4aa8c94f..0c448f09 100644 --- a/plugins/endpoints/frontend/views/EnrollmentSettings.vue +++ b/plugins/endpoints/frontend/views/EnrollmentSettings.vue @@ -125,7 +125,7 @@ {{ stateLabel(row.state) }} {{ row.agentversion || '-' }} {{ formatDate(row.lastreportedat) }} - {{ (row.blockers || []).join(', ') || '-' }} + + {{ (row.blockers || []).join(', ') || '-' }} + + Retry after fixing trust + + @@ -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); diff --git a/plugins/endpoints/frontend/views/agentKeySettings.spec.js b/plugins/endpoints/frontend/views/agentKeySettings.spec.js index f37db332..526d4fc6 100644 --- a/plugins/endpoints/frontend/views/agentKeySettings.spec.js +++ b/plugins/endpoints/frontend/views/agentKeySettings.spec.js @@ -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' }, diff --git a/plugins/endpoints/frontend/views/enrollmentSettings.spec.js b/plugins/endpoints/frontend/views/enrollmentSettings.spec.js index 9c1d59e2..9f755c75 100644 --- a/plugins/endpoints/frontend/views/enrollmentSettings.spec.js +++ b/plugins/endpoints/frontend/views/enrollmentSettings.spec.js @@ -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) diff --git a/tests/test_plugins/test_endpoints_credentials.py b/tests/test_plugins/test_endpoints_credentials.py index e8f34387..08c02c6e 100644 --- a/tests/test_plugins/test_endpoints_credentials.py +++ b/tests/test_plugins/test_endpoints_credentials.py @@ -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') diff --git a/tests/test_plugins/test_endpoints_enrollment_migration.py b/tests/test_plugins/test_endpoints_enrollment_migration.py index 1381b7ab..5d1c6330 100644 --- a/tests/test_plugins/test_endpoints_enrollment_migration.py +++ b/tests/test_plugins/test_endpoints_enrollment_migration.py @@ -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