diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index fa4ee93db3..4e70640145 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -6454,9 +6454,18 @@ exact RC/archive, and confirms one Install Update. The observer requires real EventSource open/progress, exact served version, healthy status, authenticated readiness and progress-modal completion; wrong selection, silent progress, rejected apply or a stuck modal fails. API interception and fabricated version -or progress are not acceptance. HTTP401/403 stops browser access, including -skipping a recovery login after recorded refusal; no alternate identity or apply -replay is used. +or progress are not acceptance. HTTP401/403 on main-frame navigation, login, +or any same-origin API/SSE request started after successful login stops browser +access immediately and closes the context to prevent UI/SSE reconnects. The +receipt records only bounded endpoint classes, numeric status and the fixed +journey step, never the URL, response body or credentials. Anonymous pre-login +auth probes are not authenticated refusals; their request-start identity remains +anonymous even if a response arrives after login. Version/health denial is not +a restart transport gap and is never polled again. Recovery requires a readable +terminal upgrade receipt explicitly recording `access_refused:false`; missing, +malformed or incomplete evidence also stops before another auth file or browser +process. No alternate identity, recovery login after refusal or apply replay is +used. The same harness checks systemd, the seeded settings/encrypted stores and the original auto-update/timer intent after forward update and documented CLI @@ -6480,8 +6489,13 @@ release/asset/binary identity rejection, worker-mode exclusion, scrubbed command environment and cleanup exit controls. `test_release_lifecycle_rehearsal.py` executes the recovery-access stop before another browser process can start. `tests/integration/scripts/release-browser-journey.test.cjs` exercises the real -observer against offline Chromium success/recovery and login401, wrong-target, -silent-SSE, stuck-modal and authenticated-read401 fixtures. These are observer +observer against offline Chromium success/recovery, anonymous auth probes, +restart transport gaps, wrong-target, silent-SSE and stuck-modal fixtures, plus +navigation (before/after login), login, initial/readiness version, health, +authenticated API, check/apply, SSE and protected-read HTTP401/403. Each browser +upgrade receipt is then passed to the actual shell recovery guard with a fixed +local process sentinel, proving denied access prevents later auth preparation +and browser launch while non-refused recovery remains available. These are observer and tooling proofs, **not native Tailscale/update/recovery acceptance**. That requires reviewed publication, infrastructure activation and a terminal hosted collection through the fixed Delivery dispatcher. diff --git a/scripts/release_lifecycle_rehearsal.sh b/scripts/release_lifecycle_rehearsal.sh index 22a41e1802..4818f168dc 100755 --- a/scripts/release_lifecycle_rehearsal.sh +++ b/scripts/release_lifecycle_rehearsal.sh @@ -818,11 +818,21 @@ run_updater() { run_browser_journey() { local mode="$1" expected="$2" - if [[ "$mode" == recovery && -f "${WORK_DIR}/state/browser-upgrade.json" ]] \ - && jq -e '.access_refused == true' "${WORK_DIR}/state/browser-upgrade.json" >/dev/null; then - printf '{"status":"not-executed","reason":"stopped-access-no-reauthentication"}\n' \ - > "${WORK_DIR}/state/browser-recovery.json" - return 1 + if [[ "$mode" == recovery ]]; then + # An unreadable/missing/incomplete receipt cannot authorise another + # browser process either. Only an explicit non-refused terminal result + # permits readback after the independent documented CLI recovery. + local receipt="${WORK_DIR}/state/browser-upgrade.json" reason + if ! jq -e 'type == "object" and .access_refused == false + and (.status == "passed" or .status == "failed")' "$receipt" >/dev/null 2>&1; then + reason=upgrade-receipt-unavailable + if jq -e '.access_refused == true' "$receipt" >/dev/null 2>&1; then + reason=stopped-access-no-reauthentication + fi + printf '{"status":"not-executed","reason":"%s"}\n' "$reason" \ + > "${WORK_DIR}/state/browser-recovery.json" + return 1 + fi fi (umask 077; jq -cn --arg username "$SEED_ADMIN_USER" --arg password "$ADMIN_PASSWORD" \ '{username:$username,password:$password}' > "${WORK_DIR}/state/browser-auth.json") diff --git a/scripts/tests/test_release_lifecycle_rehearsal.py b/scripts/tests/test_release_lifecycle_rehearsal.py index d8eb1c128d..dac7f9087c 100644 --- a/scripts/tests/test_release_lifecycle_rehearsal.py +++ b/scripts/tests/test_release_lifecycle_rehearsal.py @@ -196,7 +196,7 @@ class BrowserRecoveryAccessStopTest(unittest.TestCase): with self.subTest(status=status), tempfile.TemporaryDirectory() as tmp: work = Path(tmp) (work / "state").mkdir() - (work / "state/browser-upgrade.json").write_text(json.dumps({"access_refused": True, "login_status": status})) + (work / "state/browser-upgrade.json").write_text(json.dumps({"status": "failed", "access_refused": True, "login_status": status})) script = "set -euo pipefail\nWORK_DIR=" + json.dumps(tmp) + "\n" script += function + "\nrun_browser_journey recovery v6.4.5\n" # NODE/auth/origin variables are intentionally absent. Reaching @@ -208,6 +208,26 @@ class BrowserRecoveryAccessStopTest(unittest.TestCase): self.assertFalse((work / "state/browser-auth.json").exists()) self.assertEqual("", result.stderr) + def test_missing_or_malformed_upgrade_receipt_stops_before_auth_preparation(self) -> None: + source = HARNESS_PATH.read_text(encoding="utf-8") + function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}" + for receipt in (None, "not JSON", "[]", '{"status":"failed"}', + '{"status":"incomplete","access_refused":false}', + '{"status":"failed","access_refused":"false"}'): + with self.subTest(receipt=receipt), tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + (work / "state").mkdir() + if receipt is not None: + (work / "state/browser-upgrade.json").write_text(receipt) + result = subprocess.run(["bash", "-c", "set -euo pipefail\nWORK_DIR=" + + json.dumps(tmp) + "\n" + function + "\nrun_browser_journey recovery v6.4.5\n"], + capture_output=True, text=True) + self.assertEqual(1, result.returncode, result.stderr) + self.assertEqual({"status": "not-executed", "reason": "upgrade-receipt-unavailable"}, + json.loads((work / "state/browser-recovery.json").read_text())) + self.assertFalse((work / "state/browser-auth.json").exists()) + self.assertEqual("", result.stderr) + class AutoUpdateIntentTest(unittest.TestCase): """Execute the real observer/comparator, with guest systemctl observations.""" diff --git a/tests/integration/scripts/release-browser-journey.cjs b/tests/integration/scripts/release-browser-journey.cjs index bb9eecae45..2a93dcd04b 100644 --- a/tests/integration/scripts/release-browser-journey.cjs +++ b/tests/integration/scripts/release-browser-journey.cjs @@ -17,6 +17,7 @@ function validate(options) { } function judge(record) { + assert.equal(record.access_refused, false, 'denied access is never acceptance'); assert.equal(record.login_status, 200, 'authenticated UI login must succeed'); assert.equal(record.observed_version, record.expected.replace(/^v/, ''), 'wrong served version'); assert.equal(record.health, 'healthy', 'installed server is not healthy'); @@ -36,11 +37,40 @@ async function runJourney(browser, options) { const record = { schema_version: 1, mode: options.mode, from: options.from, to: options.to, expected: options.expected, browser_version: browser.version(), client_version: require('playwright/package.json').version, active_step: 'start', login_status: null, apply_requests: 0, apply_status: null, - sse_opens: 0, sse_progress: [], access_refused: false, status: 'incomplete' }; + sse_opens: 0, sse_progress: [], access_refused: false, access_refusals: [], + navigation_statuses: [], status: 'incomplete' }; assert.equal(record.client_version, '1.56.1', 'browser client must match the reviewed lock'); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); page.setDefaultTimeout(options.timeout || 30000); + const origin = new URL(options.origin).origin; + const authenticatedRequests = new WeakSet(); + let stopAccess; + const stopped = new Promise(resolve => { stopAccess = resolve; }); + let closing; + const closeContext = () => closing ||= context.close().then( + () => { record.context_closed = true; }, + () => { record.context_closed = false; }, + ); + const requireAccess = () => assert.equal(record.access_refused, false, + 'access refused; stop without another login, read or apply'); + const step = async operation => { + requireAccess(); + const value = await Promise.race([operation(), stopped.then(requireAccess)]); + requireAccess(); + return value; + }; + const refuse = (endpoint, status) => { + if (![401, 403].includes(status)) return; + if (record.access_refusals.length < 16) { + record.access_refusals.push({ endpoint, status, step: record.active_step }); + } + record.access_refused = true; // Monotonic, including automatic UI reloads. + stopAccess(); + // Stop the page too: EventSource/UI code must not reconnect after denial. + // No replacement response, alternate context or reauthentication is used. + void closeContext(); + }; // Retain only closed status/progress fields, not messages, headers, cookies, // request bodies, full response JSON or private resource data. await page.exposeFunction('__observeReleaseStream', item => { @@ -70,59 +100,89 @@ async function runJourney(browser, options) { }; }); page.on('request', request => { - if (new URL(request.url()).pathname === '/api/updates/apply' && request.method() === 'POST') { + const url = new URL(request.url()); + if (url.origin !== origin) return; + // Bind authentication at request start, not response arrival. An anonymous + // auth probe can legitimately return 401, even just after login succeeds. + if (record.login_status === 200) authenticatedRequests.add(request); + if (url.pathname === '/api/updates/apply' && request.method() === 'POST') { record.apply_requests++; } }); page.on('response', response => { - const endpoint = new URL(response.url()).pathname; - if (endpoint === '/api/login' && response.request().method() === 'POST') { - record.login_status = response.status(); - if ([401, 403].includes(response.status())) record.access_refused = true; + const url = new URL(response.url()); + if (url.origin !== origin) return; + const endpoint = url.pathname; + const request = response.request(); + if (request.isNavigationRequest() && request.frame() === page.mainFrame()) { + if (record.navigation_statuses.length < 16) record.navigation_statuses.push(response.status()); + refuse('navigation', response.status()); } - if (endpoint === '/api/updates/apply' && response.request().method() === 'POST') { + if (endpoint === '/api/login' && request.method() === 'POST') { + record.login_status = response.status(); + refuse('login', response.status()); + } + if (endpoint === '/api/updates/apply' && request.method() === 'POST') { record.apply_status = response.status(); - if ([401, 403].includes(response.status())) record.access_refused = true; + refuse('apply', response.status()); + } else if (authenticatedRequests.has(request) && endpoint.startsWith('/api/')) { + if (endpoint === '/api/config/nodes') record.protected_read_status = response.status(); + // Closed endpoint classes only: never retain a path, query, URL, body, + // error message, header, cookie or resource identity from the install. + const classes = { '/api/version': 'version', '/api/health': 'health', + '/api/config/nodes': 'protected_read', '/api/updates/check': 'update_check', + '/api/updates/stream': 'update_stream' }; + refuse(classes[endpoint] || 'authenticated_api', response.status()); } }); + const navigate = async () => { + const response = await step(() => page.goto(`${options.origin}/settings/system-updates`, + { waitUntil: 'domcontentloaded' })); + assert.equal(response?.status(), 200, 'navigation did not serve the UI'); + }; try { + record.active_step = 'navigate_login'; + await navigate(); record.active_step = 'login'; - await page.goto(`${options.origin}/settings/system-updates`, { waitUntil: 'domcontentloaded' }); - await page.getByPlaceholder('Username', { exact: true }).fill(options.auth.username); - await page.getByPlaceholder('Password', { exact: true }).fill(options.auth.password); + await step(() => page.getByPlaceholder('Username', { exact: true }).fill(options.auth.username)); + await step(() => page.getByPlaceholder('Password', { exact: true }).fill(options.auth.password)); const login = page.waitForResponse(r => new URL(r.url()).pathname === '/api/login' - && r.request().method() === 'POST'); - await page.getByRole('button', { name: 'Sign in to Pulse', exact: true }).click(); - assert.equal((await login).status(), 200, 'login rejected; do not retry or change identity'); - await page.getByPlaceholder('Username', { exact: true }).waitFor({ state: 'hidden' }); - await page.goto(`${options.origin}/settings/system-updates`, { waitUntil: 'domcontentloaded' }); + && r.request().method() === 'POST').catch(() => null); + await step(() => page.getByRole('button', { name: 'Sign in to Pulse', exact: true }).click()); + assert.equal((await step(() => login))?.status(), 200, 'login rejected; do not retry or change identity'); + await step(() => page.getByPlaceholder('Username', { exact: true }).waitFor({ state: 'hidden' })); + record.active_step = 'navigate_authenticated'; + await navigate(); if (options.mode === 'upgrade') { record.active_step = 'select_exact_release'; - const initial = await page.evaluate(async () => (await fetch('/api/version')).json()); - assert.equal(initial.version.replace(/^v/, ''), options.from.replace(/^v/, '')); - assert.equal(initial.isDocker, false, 'a Docker override is not native acceptance'); - assert.equal(initial.isSourceBuild, false, 'must run the published binary'); - await page.getByRole('button', { name: /^Preview\b/ }).click(); - await page.getByRole('button', { name: 'Save Changes', exact: true }).click(); + const initial = await step(() => page.evaluate(async () => { + const response = await fetch('/api/version'); + return { status: response.status, version: response.ok ? await response.json() : null }; + })); + assert.equal(initial.status, 200, 'initial version read rejected'); + assert.equal(initial.version.version.replace(/^v/, ''), options.from.replace(/^v/, '')); + assert.equal(initial.version.isDocker, false, 'a Docker override is not native acceptance'); + assert.equal(initial.version.isSourceBuild, false, 'must run the published binary'); + await step(() => page.getByRole('button', { name: /^Preview\b/ }).click()); + await step(() => page.getByRole('button', { name: 'Save Changes', exact: true }).click()); const check = page.waitForResponse(r => new URL(r.url()).pathname === '/api/updates/check' - && new URL(r.url()).searchParams.get('channel') === 'rc'); - await page.getByRole('button', { name: /Check for Updates/i }).click(); - const checked = await check; - if ([401, 403].includes(checked.status())) record.access_refused = true; - assert.equal(checked.status(), 200, 'update check rejected; no alternate route'); - const selected = await checked.json(); + && new URL(r.url()).searchParams.get('channel') === 'rc').catch(() => null); + await step(() => page.getByRole('button', { name: /Check for Updates/i }).click()); + const checked = await step(() => check); + assert.equal(checked?.status(), 200, 'update check rejected; no alternate route'); + const selected = await step(() => checked.json()); record.selected_version = selected.latestVersion?.replace(/^v/, ''); assert.equal(record.selected_version, options.to.replace(/^v/, ''), 'refuse a different preview'); assert.equal(selected.downloadUrl, `https://github.com/rcourtman/Pulse/releases/download/${options.to}/pulse-${options.to}-linux-amd64.tar.gz`); - await page.getByRole('button', { name: 'Install Update', exact: true }).first().click(); + await step(() => page.getByRole('button', { name: 'Install Update', exact: true }).first().click()); const confirmation = page.getByRole('dialog').filter({ has: page.getByRole('button', { name: 'Start Update' }) }); record.active_step = 'confirm_apply_once'; - await confirmation.getByRole('checkbox').check(); + await step(() => confirmation.getByRole('checkbox').check()); // No command is replayed after a timeout, rejected POST or lost response. - await confirmation.getByRole('button', { name: 'Start Update', exact: true }).click(); - await page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i }) - .waitFor({ state: 'visible' }); + await step(() => confirmation.getByRole('button', { name: 'Start Update', exact: true }).click()); + await step(() => page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i }) + .waitFor({ state: 'visible' })); } record.active_step = 'version_health_session_readiness'; const deadline = Date.now() + (options.readinessTimeout || 600000); @@ -130,21 +190,29 @@ async function runJourney(browser, options) { while (Date.now() < deadline) { // Read-only observations during a known restart may temporarily have no // listener. An HTTP401 is not such a transport gap and stops this journey. - const observed = await page.evaluate(async () => { + const observed = await step(() => page.evaluate(async () => { + const result = {}; try { const version = await fetch('/api/version'); + result.versionStatus = version.status; + if ([401, 403].includes(version.status)) return result; const health = await fetch('/api/health'); + result.healthStatus = health.status; + if ([401, 403].includes(health.status)) return result; const protectedRead = await fetch('/api/config/nodes'); - const result = { versionStatus: version.status, healthStatus: health.status, - protectedStatus: protectedRead.status }; + result.protectedStatus = protectedRead.status; + if ([401, 403].includes(protectedRead.status)) return result; try { result.version = await version.json(); result.health = await health.json(); } catch (_) { result.body_unavailable = true; } return result; - } catch (_) { return { transport_unavailable: true }; } - }).catch(() => ({ transport_unavailable: true })); // page navigation during automatic reload - record.protected_read_status = observed.protectedStatus ?? null; - if ([401, 403].includes(observed.protectedStatus)) record.access_refused = true; - assert.ok(![401, 403].includes(observed.protectedStatus), 'session read rejected; stop without reauthentication'); + } catch (_) { return { ...result, transport_unavailable: true }; } + }).catch(() => ({ transport_unavailable: true }))); // automatic reload is a transport gap, not denial + if (observed.protectedStatus !== undefined) record.protected_read_status = observed.protectedStatus; + // Also check returned statuses in case page destruction preceded the + // Playwright response event. No health/read retry follows either denial. + for (const [endpoint, status] of [['version', observed.versionStatus], + ['health', observed.healthStatus], ['protected_read', observed.protectedStatus]]) refuse(endpoint, status); + requireAccess(); if (observed.versionStatus === 200 && observed.healthStatus === 200 && observed.protectedStatus === 200 && typeof observed.version?.version === 'string' && observed.version.version.replace(/^v/, '') === options.expected.replace(/^v/, '') @@ -155,15 +223,15 @@ async function runJourney(browser, options) { ready = true; break; } - await new Promise(resolve => setTimeout(resolve, 1000)); + await step(() => new Promise(resolve => setTimeout(resolve, 1000))); } assert.ok(ready, 'readiness deadline expired'); // The UI gets its own bounded window to dismiss/reload after version readiness. record.active_step = 'progress_modal_completion'; const progress = page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i }); - await progress.waitFor({ state: 'hidden', timeout: options.timeout || 60000 }); - record.progress_dialog_visible = await progress.isVisible(); - if (options.outputDir) await page.screenshot({ path: path.join(options.outputDir, `browser-${options.mode}.png`), fullPage: true }); + await step(() => progress.waitFor({ state: 'hidden', timeout: options.timeout || 60000 })); + record.progress_dialog_visible = await step(() => progress.isVisible()); + if (options.outputDir) await step(() => page.screenshot({ path: path.join(options.outputDir, `browser-${options.mode}.png`), fullPage: true })); judge(record); record.active_step = 'complete'; record.status = 'passed'; @@ -174,8 +242,9 @@ async function runJourney(browser, options) { record.failure_type = error.name === 'TimeoutError' ? 'TimeoutError' : 'AssertionOrOperationError'; throw error; } finally { + await closeContext(); if (options.outputDir) fs.writeFileSync(path.join(options.outputDir, `browser-${options.mode}.json`), JSON.stringify(record, null, 2) + '\n'); - await context.close(); + assert.equal(record.context_closed, true, 'browser context cleanup failed'); } return record; } diff --git a/tests/integration/scripts/release-browser-journey.test.cjs b/tests/integration/scripts/release-browser-journey.test.cjs index b729378380..3dea8dfe04 100644 --- a/tests/integration/scripts/release-browser-journey.test.cjs +++ b/tests/integration/scripts/release-browser-journey.test.cjs @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); +const { spawnSync } = require('node:child_process'); const { chromium } = require('playwright'); const { validate, judge, runJourney } = require('./release-browser-journey.cjs'); const from = 'v6.4.5', to = 'v6.4.6-rc.1'; @@ -17,59 +18,101 @@ function pureControls() { assert.throws(() => validate({ ...valid, origin })); } for (const delta of [{ mode: 'apply' }, { to: 'v6.4.6' }, { expected: from }]) assert.throws(() => validate({ ...valid, ...delta })); - const receipt = { ...valid, login_status: 200, observed_version: to.slice(1), health: 'healthy', + const receipt = { ...valid, access_refused: false, login_status: 200, observed_version: to.slice(1), health: 'healthy', protected_read_status: 200, apply_requests: 1, apply_status: 200, selected_version: to.slice(1), sse_opens: 1, sse_progress: [{ status: 'downloading', progress: 10 }], progress_dialog_visible: false }; judge(receipt); - for (const delta of [{ login_status: 401 }, { observed_version: from.slice(1) }, { health: 'unhealthy' }, + for (const delta of [{ access_refused: true }, { login_status: 401 }, { observed_version: from.slice(1) }, { health: 'unhealthy' }, { protected_read_status: 403 }, { apply_requests: 0 }, { apply_requests: 2 }, { apply_status: 500 }, { selected_version: '6.5.0-rc.1' }, { sse_opens: 0 }, { sse_progress: [] }, { progress_dialog_visible: true }]) { assert.throws(() => judge({ ...receipt, ...delta })); } } -function fixture(kind, mode) { - let version = (mode === 'recovery' ? from : from).slice(1), applies = 0, logins = 0; - let applied = false; - const sockets = new Set(); +function fixture(kind) { + let version = from.slice(1), applied = false, denied = false; + const counters = { applies: 0, logins: 0, version_reads: 0, health_reads: 0, + protected_reads: 0, streams: 0, denials: 0, anonymous_probes: 0, + requests_after_denial: 0, transport_gaps: 0 }; + const denial = /^(.*)-(401|403)$/.exec(kind); + const sockets = new Set(), timers = new Set(); + const later = (fn, ms) => { const timer = setTimeout(() => { timers.delete(timer); fn(); }, ms); timers.add(timer); }; const server = http.createServer((req, res) => { + if (denied) counters.requests_after_denial++; const url = new URL(req.url, 'http://localhost'); + const signedIn = /fixture-session=true/.test(req.headers.cookie || ''); const json = (code, data) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(data)); }; + const refuse = endpoint => { + if (denial?.[1] !== endpoint) return false; + denied = true; counters.denials++; + json(Number(denial[2]), { error: 'DO NOT RETAIN FIXTURE PRIVATE DENIAL' }); + return true; + }; if (url.pathname === '/api/login') { - logins++; - if (kind === 'login-401') return json(401, { error: 'fixture denial' }); + counters.logins++; + if (refuse('login')) return; res.setHeader('Set-Cookie', 'fixture-session=true; Path=/; HttpOnly; SameSite=Lax'); return json(200, { success: true }); } - if (url.pathname === '/api/version') return json(200, { version, isDocker: false, isSourceBuild: false }); - if (url.pathname === '/api/health') return json(200, { status: 'healthy' }); - if (url.pathname === '/api/config/nodes') return json(applied && kind === 'read-401' ? 401 : 200, []); - if (url.pathname === '/api/updates/check') return json(200, { - latestVersion: kind === 'wrong-preview' ? '6.5.0-rc.1' : to.slice(1), downloadUrl }); + if (url.pathname === '/api/security/status' || url.pathname === '/api/config/nodes' && !signedIn) { + counters.anonymous_probes++; + return json(url.pathname === '/api/security/status' ? 401 : 403, { error: 'normal anonymous auth probe' }); + } + if (url.pathname === '/api/version') { + counters.version_reads++; + if (refuse(applied ? 'version' : 'initial-version')) return; + if (applied && kind === 'restart-gap' && counters.transport_gaps === 0) { + counters.transport_gaps++; res.destroy(); return; + } + return json(200, { version, isDocker: false, isSourceBuild: false }); + } + if (url.pathname === '/api/health') { + counters.health_reads++; + if (refuse('health')) return; + return json(200, { status: 'healthy' }); + } + if (url.pathname === '/api/config/nodes') { + counters.protected_reads++; + if (applied && refuse('read')) return; + return json(200, []); + } + if (url.pathname === '/api/settings/system') { + if (refuse('authenticated-api')) return; + return json(200, {}); + } + if (url.pathname === '/api/updates/check') { + if (refuse('check')) return; + return json(200, { latestVersion: kind === 'wrong-preview' ? '6.5.0-rc.1' : to.slice(1), downloadUrl }); + } if (url.pathname === '/api/updates/apply') { - applies++; applied = true; - setTimeout(() => { version = to.slice(1); }, 350); + counters.applies++; + if (refuse('apply')) return; + applied = true; + later(() => { version = to.slice(1); }, 350); return json(200, { status: 'started' }); } if (url.pathname === '/api/updates/stream') { + counters.streams++; + if (refuse('stream')) return; res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); res.write(': real loopback fixture stream\n\n'); if (kind !== 'silent-stream') res.write(`data: ${JSON.stringify({ status: 'downloading', progress: 10, message: 'DO NOT RETAIN FIXTURE PRIVATE MESSAGE' })}\n\n`); req.on('close', () => res.end()); return; } + if (refuse(signedIn ? 'authenticated-navigation' : 'navigation')) return; res.writeHead(200, { 'content-type': 'text/html' }); - const signedIn = /fixture-session=true/.test(req.headers.cookie || ''); res.end(`
Published fixture ${version}
` : ` - `}`); + `}