From ee07be5a53ca1c85369e79edf648e644bbc8aaf5 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:51:53 +0100 Subject: [PATCH] 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 --- .github/workflows/test-e2e.yml | 4 + .../integration/scripts/fixture-auth.test.mjs | 232 ++++++++++++++++++ tests/integration/tests/helpers.ts | 61 ++++- .../04-agent-install-registration.spec.ts | 17 +- 4 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 tests/integration/scripts/fixture-auth.test.mjs diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 10ea2aedde..c3d29e5f58 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -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. diff --git a/tests/integration/scripts/fixture-auth.test.mjs b/tests/integration/scripts/fixture-auth.test.mjs new file mode 100644 index 0000000000..c4abb6ca7b --- /dev/null +++ b/tests/integration/scripts/fixture-auth.test.mjs @@ -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\(\)/); +}); diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index a830f5ebd6..62cb76b7a1 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -1091,8 +1091,12 @@ async function authenticateWithPrimaryAPIToken(page: Page): Promise { 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 { + let cookies: Awaited["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 { + 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, diff --git a/tests/integration/tests/journeys/04-agent-install-registration.spec.ts b/tests/integration/tests/journeys/04-agent-install-registration.spec.ts index ad45a17c76..a587253a65 100644 --- a/tests/integration/tests/journeys/04-agent-install-registration.spec.ts +++ b/tests/integration/tests/journeys/04-agent-install-registration.spec.ts @@ -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; expect( findRegisteredAgentResource(state),