Stop release browser access at every authenticated refusal

Bind anonymous probes at request start, record bounded navigation/API/SSE denial, close the page immediately and suppress recovery authentication when the upgrade receipt is refused or unavailable. Exercise real Chromium response controls through the actual shell recovery guard.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 03:44:51 +01:00
parent f0ff193402
commit 73b790b89b
5 changed files with 297 additions and 87 deletions
@@ -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.
+15 -5
View File
@@ -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")
@@ -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."""
@@ -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;
}
@@ -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(`<!doctype html><html><head><title>Offline release observer control</title></head>
<body><h1>Observer fixture — not Pulse/native qualification</h1>${signedIn ? `
<h2>System updates</h2><button id="preview">Preview beta builds</button><button id="save">Save Changes</button>
<button id="check">Check for Updates</button><button id="install" hidden>Install Update</button>
<main id="dialogs"></main><p id="state">Published fixture ${version}</p>
<script>
document.querySelector('#save').onclick = async () => { await fetch('/api/settings/system', {method:'POST'}); };
document.querySelector('#check').onclick = async () => { await fetch('/api/updates/check?channel=rc'); document.querySelector('#install').hidden=false; };
document.querySelector('#install').onclick = () => {
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Confirm update"><h2>Confirm Update</h2><label><input type="checkbox"> Acknowledge</label><button id="start">Start Update</button></section>';
document.querySelector('#start').onclick = async () => {
await fetch('/api/updates/apply', {method:'POST'});
const response = await fetch('/api/updates/apply', {method:'POST'}); if (!response.ok) return;
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Updating Pulse"><h2>Updating Pulse</h2><p>Downloading update 10%</p></section>';
const stream=new EventSource('/api/updates/stream');
setTimeout(() => { ${kind === 'stuck-modal' ? '' : "document.querySelector('#dialogs').innerHTML='';"}
@@ -78,29 +121,69 @@ function fixture(kind, mode) {
};
</script>` : `
<input placeholder="Username"><input placeholder="Password" type="password"><button id="login">Sign in to Pulse</button>
<script>document.querySelector('#login').onclick = async () => { const response=await fetch('/api/login', {method:'POST'}); if(response.ok)location.reload(); };</script>`}</body></html>`);
<script>
const probes = ${kind === 'anonymous-probes' ? "Promise.all([fetch('/api/security/status'), fetch('/api/config/nodes')])" : 'Promise.resolve()'};
document.querySelector('#login').onclick = async () => { await probes; const response=await fetch('/api/login', {method:'POST'}); if(response.ok)location.reload(); };
</script>`}</body></html>`);
});
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
return { server, counts: () => ({ applies, logins }), close: async () => {
return { server, counts: () => ({ ...counters }), close: async () => {
for (const timer of timers) clearTimeout(timer);
for (const socket of sockets) socket.destroy();
await new Promise(resolve => server.close(resolve));
} };
}
function recoveryGuard(folder, refused) {
// Exercise the ACTUAL shell guard with this browser's receipt. The permitted
// branch's process is a fixed local sentinel, not installation or recovery.
const state = path.join(folder, 'state'); fs.mkdirSync(state);
fs.copyFileSync(path.join(folder, 'browser-upgrade.json'), path.join(state, 'browser-upgrade.json'));
const invocation = path.join(folder, 'guard-process-started');
const sentinel = path.join(folder, 'process-sentinel.sh');
fs.writeFileSync(sentinel, `#!/bin/sh\nprintf started > ${JSON.stringify(invocation)}\n`, { mode: 0o700 });
const harness = fs.readFileSync(path.resolve(__dirname, '../../../scripts/release_lifecycle_rehearsal.sh'), 'utf8');
const body = 'run_browser_journey() {' + harness.split('run_browser_journey() {')[1].split('\n}\n')[0] + '\n}';
const result = spawnSync('bash', ['-c', `set -euo pipefail\n${body}\nrun_browser_journey recovery v6.4.5\n`], {
env: { PATH: '/usr/local/bin:/usr/bin:/bin', WORK_DIR: folder, ROOT_DIR: path.resolve(__dirname, '../../..'),
SEED_ADMIN_USER: 'synthetic', ADMIN_PASSWORD: 'synthetic', PULSE_REHEARSAL_NODE: sentinel,
PULSE_REHEARSAL_BROWSER_ORIGIN: 'https://ephemeral.tawny-powan.ts.net', FROM_TAG: from, TO_TAG: to },
encoding: 'utf8', timeout: 5000,
});
assert.equal(result.error, undefined, 'shell guard must execute');
assert.equal(result.stderr, '', 'no raw error/receipt contents');
assert.equal(result.status, refused ? 1 : 0, 'actual recovery guard verdict');
assert.equal(fs.existsSync(invocation), !refused, 'no later browser process after refusal');
assert.equal(fs.existsSync(path.join(state, 'browser-auth.json')), !refused, 'no later auth preparation after refusal');
if (refused) assert.deepEqual(JSON.parse(fs.readFileSync(path.join(state, 'browser-recovery.json'))),
{ status: 'not-executed', reason: 'stopped-access-no-reauthentication' });
else fs.writeFileSync(path.join(state, 'browser-auth.json'), '');
return { executed_guard: true, exit: result.status, browser_process_started: !refused, auth_prepared: !refused };
}
async function runControls(outputDir) {
pureControls();
const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
const results = [];
try {
for (const [kind, mode, expectedPass, expectedApplies] of [
['success', 'upgrade', true, 1], ['success', 'recovery', true, 0],
['login-401', 'upgrade', false, 0], ['wrong-preview', 'upgrade', false, 0],
['silent-stream', 'upgrade', false, 1], ['stuck-modal', 'upgrade', false, 1],
['read-401', 'upgrade', false, 1],
]) {
const target = fixture(kind, mode);
const controls = [
['success', 'upgrade', true, 1, 1], ['success', 'recovery', true, 0, 1],
['anonymous-probes', 'upgrade', true, 1, 1], ['restart-gap', 'upgrade', true, 1, 1],
['wrong-preview', 'upgrade', false, 0, 1], ['silent-stream', 'upgrade', false, 1, 1],
['stuck-modal', 'upgrade', false, 1, 1],
];
for (const endpoint of ['navigation', 'authenticated-navigation', 'login', 'initial-version',
'authenticated-api', 'check', 'apply', 'stream', 'version', 'health', 'read']) {
for (const status of [401, 403]) {
controls.push([`${endpoint}-${status}`, 'upgrade', false,
['apply', 'stream', 'version', 'health', 'read'].includes(endpoint) ? 1 : 0,
endpoint === 'navigation' ? 0 : 1]);
}
}
for (const [kind, mode, expectedPass, expectedApplies, expectedLogins] of controls) {
const target = fixture(kind);
await new Promise(resolve => target.server.listen(0, '127.0.0.1', resolve));
const folder = path.join(outputDir, `${kind}-${mode}`); fs.mkdirSync(folder, { recursive: true });
const folder = path.resolve(outputDir, `${kind}-${mode}`); fs.mkdirSync(folder, { recursive: true });
let passed = false;
try {
await runJourney(browser, { origin: `http://127.0.0.1:${target.server.address().port}`, mode, from, to,
@@ -110,13 +193,27 @@ async function runControls(outputDir) {
} catch (_) { /* observer must reject each deliberately adverse fixture */ }
finally { await target.close(); }
assert.equal(passed, expectedPass, `${kind}/${mode} observer verdict`);
assert.deepEqual(target.counts(), { applies: expectedApplies, logins: 1 }, 'one login, no apply replay');
const counts = target.counts();
assert.equal(counts.applies, expectedApplies, 'one apply, no replay');
assert.equal(counts.logins, expectedLogins, 'no login after navigation denial');
assert.equal(counts.requests_after_denial, 0, 'page stops after denial, including SSE reconnect');
const receipt = JSON.parse(fs.readFileSync(path.join(folder, `browser-${mode}.json`)));
assert.ok(!JSON.stringify(receipt).includes('PRIVATE MESSAGE'));
assert.ok(!JSON.stringify(receipt).includes('synthetic'));
if (kind === 'read-401') assert.equal(receipt.protected_read_status, 401);
assert.equal(receipt.context_closed, true, 'browser context cleaned up');
const refusal = /-(401|403)$/.test(kind);
assert.equal(receipt.access_refused, refusal, 'only actual access refusal stops later access');
if (refusal) {
assert.equal(receipt.access_refusals[0].status, Number(kind.slice(-3)));
assert.equal(counts.denials, 1, 'denied request is not repeated');
assert.ok(!JSON.stringify(receipt).includes('PRIVATE DENIAL'));
}
if (kind.startsWith('read-')) assert.equal(receipt.protected_read_status, Number(kind.slice(-3)));
if (kind === 'anonymous-probes') assert.equal(counts.anonymous_probes, 2, 'normal pre-login denial controls ran');
if (kind === 'restart-gap') assert.equal(counts.transport_gaps, 1, 'restart transport control ran');
const recovery = mode === 'upgrade' ? recoveryGuard(folder, refusal) : null;
results.push({ fixture: kind, mode, expected_rejection: !expectedPass, observed_status: receipt.status,
active_step: receipt.active_step, ...target.counts() });
active_step: receipt.active_step, ...counts, recovery_guard: recovery, receipt });
}
fs.writeFileSync(path.join(outputDir, 'observer-controls.json'), JSON.stringify({ scope: 'offline adversarial observer controls only',
browser_version: browser.version(), client_version: require('playwright/package.json').version, results }, null, 2) + '\n');