Reuse live E2E sessions and keep removal verification authenticated

Reuse the existing admitted cookie session only after a cookie-only request validates it against the current backend. Keep explicit session and token tests independent, reject revoked state, and propagate unexpected probe failures. The registration journey uses its documented cookie/CSRF authentication so removing a dedicated host token cannot invalidate its removal observer. Retain both the HTTP-success and removed-resource assertions, add behavioral adverse controls and run them in ordinary E2E CI.

Contract-Neutral: Test fixtures reuse validated cookie sessions and select documented cookie auth; production authentication budgets, token revocation, API and tenant contracts are unchanged.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-10 17:51:53 +01:00
parent dfb4a083d8
commit ee07be5a53
4 changed files with 307 additions and 7 deletions
+4
View File
@@ -90,6 +90,10 @@ jobs:
working-directory: tests/integration
run: node --test scripts/report-stable-e2e-failures.test.mjs
- name: Validate fixture authentication and credential lifetime
working-directory: tests/integration
run: node scripts/fixture-auth.test.mjs
offline-org-provisioning:
name: Offline Organization provisioning
# Fixture acceptance only, not quarantine promotion or private RBAC proof.
@@ -0,0 +1,232 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { stripTypeScriptTypes } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = fs.readFileSync(
process.env.PULSE_FIXTURE_AUTH_SOURCE || path.join(root, 'tests/helpers.ts'),
'utf8',
);
const fragment = (start, end) => {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
assert.ok(from >= 0 && to > from, `missing helper boundary: ${start}`);
return source.slice(from, to).replace(/^export /gm, '');
};
// Execute the real public helpers and their cookie-reuse implementation, not
// a second implementation. Strip types with the governed Node 24 runtime;
// stub only the browser/backend boundaries. No listener or credentials needed.
const helpers = stripTypeScriptTypes([
source.match(/const AUTHENTICATED_URL =[\s\S]*?;\n/)[0],
fragment('export async function ensureAuthenticated(', 'async function storageStateHasLiveSession('),
fragment('export async function apiRequest(', 'export async function isMultiTenantEnabled('),
'({ ensureAuthenticated, ensureSessionAuthenticated, apiRequest });',
].join('\n'));
const cookie = (name, value) => ({
name, value, domain: '127.0.0.1', path: '/', expires: -1,
httpOnly: name === 'pulse_session', secure: false, sameSite: 'Lax',
});
const response = status => ({ status: () => status, ok: () => status === 200 });
function fixture(t, options = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-fixture-auth-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const statePath = path.join(directory, 'session.json');
const sessions = new Set(['saved-live']);
let loginCalls = 0;
let probeCalls = 0;
let disposed = 0;
let primaryRevoked = false;
let registeredWithToken = false;
const requests = [];
const primary = options.primary || '';
const validSession = cookies => cookies.some(
item => item.name === 'pulse_session' && sessions.has(item.value) &&
item.domain === '127.0.0.1' && (item.expires === -1 || item.expires > Date.now() / 1000),
);
const fetch = (cookies, url, args = {}) => {
const method = args.method || 'GET';
const headers = args.headers || {};
requests.push({ method, headers });
const tokenAuth = !primaryRevoked && primary && headers['X-API-Token'] === primary;
if (!validSession(cookies) && !tokenAuth) return response(401);
if (validSession(cookies) && method !== 'GET' && headers['X-CSRF-Token'] !== 'csrf') {
return response(403);
}
if (url.endsWith('/report') && method === 'POST') registeredWithToken = !!tokenAuth;
if (url.endsWith('/fixture-host') && method === 'DELETE' && registeredWithToken) {
primaryRevoked = true;
}
return response(200);
};
const createPage = () => {
let cookies = [];
let url = 'about:blank';
const context = {
cookies: async () => cookies,
addCookies: async values => { cookies = [...cookies, ...values]; },
};
return {
context: () => context,
request: { fetch: async (target, args) => fetch(cookies, target, args) },
goto: async () => { url = validSession(cookies) ? 'http://127.0.0.1:7655/proxmox' : 'http://127.0.0.1:7655/'; },
url: () => url,
};
};
const api = runInNewContext(helpers, {
fs, path, helpersDir: path.join(root, 'tests'),
process: { pid: process.pid, env: { PULSE_E2E_COOKIE_STATE_PATH: statePath } },
preferredBrowserBaseURL: () => 'http://127.0.0.1:7655',
preferredPlaywrightRouteBaseURL: () => 'http://127.0.0.1:7655',
waitForPulseReady: async () => {},
maybeCompleteSetupWizard: async () => {},
waitForAppShell: async () => {},
authenticateWithPrimaryAPIToken: async page => {
if (!options.tokenAuth) return false;
page.url = () => 'http://127.0.0.1:7655/proxmox';
return true;
},
login: async page => {
loginCalls++;
if (options.loginFailure) throw new Error('Login failed: fixture rejection');
const value = `login-${loginCalls}`;
sessions.add(value);
await page.context().addCookies([cookie('pulse_session', value), cookie('pulse_csrf', 'csrf')]);
await page.goto('/');
},
expect: page => ({ toHaveURL: async pattern => assert.match(page.url(), pattern) }),
playwrightRequest: { newContext: async ({ storageState, baseURL }) => {
assert.equal(baseURL, 'http://127.0.0.1:7655');
assert.equal(storageState.origins.length, 0);
return {
get: async endpoint => {
assert.equal(endpoint, '/api/state');
probeCalls++;
return response(options.probeStatus ?? (validSession(storageState.cookies) ? 200 : 401));
},
dispose: async () => { disposed++; },
};
} },
configuredPrimaryAPIToken: () => primary,
getTokenAuthRequestContext: async () => ({ fetch: async (url, args) => fetch([], url, args) }),
});
return {
...api, createPage, statePath, sessions, requests,
counts: () => ({ loginCalls, probeCalls, disposed, primaryRevoked }),
save: (cookies, origins = []) => fs.writeFileSync(statePath, JSON.stringify({ cookies, origins })),
};
}
test('twelve generic page fixtures reuse one password session, without backoff or retries', async t => {
const f = fixture(t);
for (let index = 0; index < 12; index++) await f.ensureAuthenticated(f.createPage());
assert.equal(f.counts().loginCalls, 1);
assert.equal(f.counts().probeCalls, 11);
assert.equal(f.counts().disposed, 11);
assert.equal(fs.statSync(f.statePath).mode & 0o777, 0o600);
assert.deepEqual(JSON.parse(fs.readFileSync(f.statePath)).origins, []);
});
test('the existing admitted cookie session avoids another login and does not import org state', async t => {
const f = fixture(t);
f.save([
cookie('pulse_session', 'saved-live'), cookie('pulse_csrf', 'csrf'),
cookie('pulse_org_id', 'another-test-org'), cookie('unrelated', 'another-value'),
], [{ origin: 'http://127.0.0.1:7655', localStorage: [{ name: 'pulse_org_id', value: 'another-test-org' }] }]);
const page = f.createPage();
await f.ensureAuthenticated(page);
assert.equal(f.counts().loginCalls, 0);
assert.deepEqual((await page.context().cookies()).map(item => item.name), ['pulse_session', 'pulse_csrf']);
assert.equal(f.counts().disposed, 1);
});
for (const scenario of ['revoked', 'expired', 'other-backend', 'missing-session', 'malformed-json']) {
test(`${scenario} saved state is not accepted; ordinary login replaces it`, async t => {
const f = fixture(t);
if (scenario === 'malformed-json') fs.writeFileSync(f.statePath, '{');
else {
const saved = cookie(scenario === 'missing-session' ? 'pulse_csrf' : 'pulse_session', scenario);
if (scenario === 'expired') Object.assign(saved, { value: 'saved-live', expires: 1 });
if (scenario === 'other-backend') Object.assign(saved, { value: 'saved-live', domain: 'another-backend.test' });
f.save([saved]);
}
const page = f.createPage();
await f.ensureAuthenticated(page);
assert.equal(f.counts().loginCalls, 1);
assert.equal((await page.context().cookies()).some(item => item.value === scenario), false);
assert.equal(JSON.parse(fs.readFileSync(f.statePath)).cookies[0].value, 'login-1');
assert.equal(f.counts().disposed, f.counts().probeCalls);
});
}
test('a denied shared session and denied password login remain a failure', async t => {
const f = fixture(t, { loginFailure: true });
f.save([cookie('pulse_session', 'revoked')]);
await assert.rejects(() => f.ensureAuthenticated(f.createPage()), /Login failed: fixture rejection/);
assert.equal(f.counts().disposed, 1);
assert.equal(JSON.parse(fs.readFileSync(f.statePath)).cookies[0].value, 'revoked');
});
test('a failed backend probe is not hidden by another login', async t => {
const f = fixture(t, { probeStatus: 500 });
f.save([cookie('pulse_session', 'saved-live')]);
await assert.rejects(() => f.ensureAuthenticated(f.createPage()), /Shared cookie session probe failed: 500/);
assert.equal(f.counts().loginCalls, 0);
assert.equal(f.counts().disposed, 1);
});
test('primary-token fixtures keep their token auth and import no ambient cookie session', async t => {
const f = fixture(t, { tokenAuth: true });
f.save([cookie('pulse_session', 'saved-live')]);
const page = f.createPage();
await f.ensureAuthenticated(page);
assert.equal(f.counts().loginCalls, 0);
assert.equal(f.counts().probeCalls, 0);
assert.equal((await page.context().cookies()).length, 0);
});
test('explicit session fixtures still get independent password sessions', async t => {
const f = fixture(t, { tokenAuth: true });
f.save([cookie('pulse_session', 'saved-live')]);
await f.ensureSessionAuthenticated(f.createPage());
await f.ensureSessionAuthenticated(f.createPage());
assert.equal(f.counts().loginCalls, 2);
assert.equal(f.counts().probeCalls, 0);
assert.equal(JSON.parse(fs.readFileSync(f.statePath)).cookies[0].value, 'saved-live');
});
test('cookie-authenticated registration can verify deletion without binding the primary token', async t => {
const f = fixture(t, { primary: 'fixture-primary', tokenAuth: true });
const page = f.createPage();
await f.ensureSessionAuthenticated(page);
for (const [endpoint, method] of [
['/api/agents/agent/report', 'POST'], ['/api/agents/agent/fixture-host', 'DELETE'], ['/api/state', 'GET'],
]) assert.equal((await f.apiRequest(page, endpoint, { method })).status(), 200);
assert.equal(f.counts().primaryRevoked, false);
assert.equal(f.requests.some(item => item.headers['X-API-Token']), false);
assert.equal(f.requests[0].headers['X-CSRF-Token'], 'csrf');
});
test('negative control: deletion revokes a bound token and rejects its subsequent read', async t => {
const f = fixture(t, { primary: 'fixture-primary', tokenAuth: true });
const page = f.createPage();
await f.ensureAuthenticated(page);
assert.equal((await f.apiRequest(page, '/api/agents/agent/report', { method: 'POST' })).status(), 200);
assert.equal((await f.apiRequest(page, '/api/agents/agent/fixture-host', { method: 'DELETE' })).status(), 200);
assert.equal((await f.apiRequest(page, '/api/state')).status(), 401);
assert.equal(f.counts().primaryRevoked, true);
});
test('the agent journey selects session auth and retains deletion/state assertions', () => {
const spec = fs.readFileSync(path.join(root, 'tests/journeys/04-agent-install-registration.spec.ts'), 'utf8');
assert.match(spec, /ensureSessionAuthenticated/);
assert.doesNotMatch(spec, /ensureAuthenticated/);
assert.match(spec, /`Post-deletion state request failed: \$\{stateRes.status\(\)\}`/);
assert.match(spec, /findRegisteredAgentResource\(state\),\s*"Host should be removed from state after deletion",\s*\).toBeFalsy\(\)/);
});
+60 -1
View File
@@ -1091,8 +1091,12 @@ async function authenticateWithPrimaryAPIToken(page: Page): Promise<boolean> {
export async function ensureAuthenticated(page: Page) {
await waitForPulseReady(page);
await maybeCompleteSetupWizard(page);
if (!(await authenticateWithPrimaryAPIToken(page))) {
if (
!(await authenticateWithPrimaryAPIToken(page)) &&
!(await authenticateWithSharedCookieSession(page))
) {
await login(page);
await rememberSharedCookieSession(page);
}
await expect(page).toHaveURL(AUTHENTICATED_URL);
}
@@ -1122,6 +1126,61 @@ const sharedCookieStatePath = (): string =>
"shared-cookie-session.json",
);
const isAuthenticationCookie = (cookie: { name: string }): boolean =>
["pulse_session", "__Host-pulse_session", "pulse_csrf"].includes(cookie.name);
async function authenticateWithSharedCookieSession(page: Page): Promise<boolean> {
let cookies: Awaited<ReturnType<ReturnType<Page["context"]>["cookies"]>>;
try {
const state = JSON.parse(fs.readFileSync(sharedCookieStatePath(), "utf8"));
cookies = state.cookies.filter(isAuthenticationCookie);
} catch {
return false;
}
if (!cookies.some((cookie) => cookie.name !== "pulse_csrf")) {
return false;
}
// Probe the exact saved cookies against this backend, without a primary
// token that could hide expiry or revocation. Never import another test's
// org selection, localStorage or unrelated cookies into this page.
const probe = await playwrightRequest.newContext({
baseURL: preferredBrowserBaseURL(),
storageState: { cookies, origins: [] },
});
try {
const status = (await probe.get("/api/state")).status();
if (status === 401 || status === 403) {
return false;
}
if (status !== 200) {
throw new Error(`Shared cookie session probe failed: ${status}`);
}
await page.context().addCookies(cookies);
await page.goto("/");
await waitForAppShell(page);
await expect(page).toHaveURL(AUTHENTICATED_URL);
return true;
} finally {
await probe.dispose();
}
}
async function rememberSharedCookieSession(page: Page): Promise<void> {
const cookies = (await page.context().cookies(preferredBrowserBaseURL()))
.filter(isAuthenticationCookie);
if (!cookies.some((cookie) => cookie.name !== "pulse_csrf")) {
throw new Error("Password authentication did not issue a cookie session");
}
const sharedPath = sharedCookieStatePath();
fs.mkdirSync(path.dirname(sharedPath), { recursive: true });
const temporaryPath = `${sharedPath}.${process.pid}.tmp`;
fs.writeFileSync(temporaryPath, JSON.stringify({ cookies, origins: [] }), {
mode: 0o600,
});
fs.renameSync(temporaryPath, sharedPath);
}
async function storageStateHasLiveSession(
browser: Browser,
statePath: string,
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import {
ensureAuthenticated,
ensureSessionAuthenticated,
apiRequest,
getMockMode,
setMockMode,
@@ -20,8 +20,10 @@ import {
* This satisfies L12 score-4 criteria: "Agent install → registration →
* host visible in UI/API."
*
* The test simulates agent registration via the report API. Session-based
* (browser) auth bypasses the scope check, so no API token is needed.
* The test simulates agent registration via the report API using the browser's
* cookie session and CSRF token. Do not bind the fixture's primary API token to
* this host: deletion revokes a host's dedicated token, so it cannot also be
* the credential used to verify removal. Token scope/revocation has its own tests.
* For full agent binary install tests in LXC sandbox, set
* PULSE_E2E_AGENT_BINARY to skip simulation and use a real agent.
*/
@@ -207,7 +209,7 @@ test.describe("Journey: Agent Install → Registration → Host Visible", () =>
const context = await browser.newContext();
const page = await context.newPage();
try {
await ensureAuthenticated(page);
await ensureSessionAuthenticated(page);
if ((await getMockMode(page)).enabled) {
await setMockMode(page, false);
}
@@ -229,7 +231,7 @@ test.describe("Journey: Agent Install → Registration → Host Visible", () =>
);
test.setTimeout(120_000);
await ensureAuthenticated(page);
await ensureSessionAuthenticated(page);
try {
const report = buildHostReport();
@@ -351,7 +353,10 @@ test.describe("Journey: Agent Install → Registration → Host Visible", () =>
).toBeTruthy();
const stateRes = await apiRequest(page, "/api/state");
expect(stateRes.ok()).toBeTruthy();
expect(
stateRes.ok(),
`Post-deletion state request failed: ${stateRes.status()}`,
).toBeTruthy();
const state = (await stateRes.json()) as Record<string, unknown>;
expect(
findRegisteredAgentResource(state),