mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
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:
@@ -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\(\)/);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user