mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user