One sign-in drive for every e2e, the public surface and visibility in the rights matrix, and the stack gates in validate
e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
This commit is contained in:
+24
-50
@@ -13,12 +13,22 @@
|
||||
* only (the app's FRONT_URL), so the app MUST be served at APP_URL — do not
|
||||
* serve a private instance on an ephemeral port: the pod rejects the redirect.
|
||||
*
|
||||
* ONE LOGIN DRIVE. The sign-in is `driveLogin` from `e2e-rights-matrix-support.ts`,
|
||||
* shared with the rights matrix: a state machine over OBSERVED hops (it must
|
||||
* leave the app for the pod, the consent Accept is clicked once stable and the
|
||||
* page must then leave the consent screen, and the shell counts only with the
|
||||
* bottom navigation shown and no picker). The previous local loop could report
|
||||
* "shell reached" while still on `:4000/` — the user never consented, the app
|
||||
* was never installed. `authenticated` also requires the install to be visible
|
||||
* on the pod (the user's registered Event container answers 200).
|
||||
*
|
||||
* The two benign ≥400 classes (the pod notification WebSocket channel, and an
|
||||
* anonymous WAC profile read) are classified, never fatal. Anything else fails.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { podLogin, readResource, type ApodsStackConfig } from '../src/shared/support/activitypodsStack';
|
||||
import type { ApodsStackConfig } from '../src/shared/support/activitypodsStack';
|
||||
import { driveLogin, installedContainer, loginWithRetry, type DriveContext } from './e2e-rights-matrix-support';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
@@ -27,6 +37,7 @@ const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
const POD_FRONT: string = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||||
|
||||
const USER = `flow${Date.now()}`;
|
||||
const PASS = 'Test1test';
|
||||
@@ -37,9 +48,9 @@ interface HttpError { url: string; status: number; method: string; body: string
|
||||
const redirects: string[] = [];
|
||||
const errors4xx: HttpError[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const hops: string[] = [];
|
||||
/** The shared login drive's context: it writes the observed hops; HTTP and page errors are collected by `wire` below. */
|
||||
const drive: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: DEADLINE };
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
const snap = (page: Page, name: string): Promise<void> =>
|
||||
@@ -65,46 +76,6 @@ function wire(page: Page): void {
|
||||
page.on('pageerror', e => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 300)));
|
||||
}
|
||||
|
||||
/** Drive picker → pod login → consent until the app shell is reached. */
|
||||
async function driveLogin(page: Page): Promise<boolean> {
|
||||
let authorizeSeen = 0;
|
||||
for (let i = 0; i < 20 && Date.now() < DEADLINE; i++) {
|
||||
const url = page.url();
|
||||
if (hops[hops.length - 1] !== clean(url)) hops.push(clean(url));
|
||||
|
||||
if (url.includes(':5000') && (await page.locator('input[name="username"]').count().catch(() => 0))) {
|
||||
hops.push(' [login] submitting credentials');
|
||||
await page.locator('input[name="username"]').fill(USER).catch(() => {});
|
||||
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
|
||||
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
if (await btn.count()) await btn.first().click().catch(() => {});
|
||||
else await page.locator('button[type=submit]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(3500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.includes(':5000/authorize')) {
|
||||
authorizeSeen++;
|
||||
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
let visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
hops.push(` [authorize #${authorizeSeen}] accept visible=${visible}`);
|
||||
await snap(page, 'login-flow-consent');
|
||||
if (visible) { await acc.first().click().catch(() => {}); hops.push(' [authorize] clicked accept'); }
|
||||
if (authorizeSeen >= 3) { hops.push(' [LOOP] authorize revisited 3x — consent loop'); return false; }
|
||||
await page.waitForTimeout(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.startsWith(APP)) {
|
||||
if (url.includes('/login')) { await page.waitForTimeout(5000); continue; }
|
||||
const picker = await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0);
|
||||
if (!picker) { hops.push(' [app] shell reached'); return true; }
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function createEvent(page: Page): Promise<boolean> {
|
||||
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
@@ -142,19 +113,20 @@ function classify(e: HttpError): string | null {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Sign in (signup fallback) via the shared helper — the UI login then uses the same creds.
|
||||
await podLogin(config, USER, PASS);
|
||||
// Sign in (signup fallback, retried: a fresh signup can outlive the helper's
|
||||
// timeout while succeeding server-side) — the UI login then uses the same creds.
|
||||
const session = await loginWithRetry(config, USER, PASS);
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
wire(page);
|
||||
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
|
||||
const authenticated = await driveLogin(page);
|
||||
// The shared drive opens the app, clicks the pod's sign-in button and follows the observed hops.
|
||||
const login = await driveLogin(page, 'flow', USER, PASS, drive);
|
||||
await snap(page, 'login-flow-after-login');
|
||||
const install = login.ok ? await installedContainer(session) : { ok: false, actual: 'not checked: the sign-in drive failed' };
|
||||
const authenticated = login.ok && install.ok;
|
||||
const eventCreated = authenticated ? await createEvent(page) : false;
|
||||
await snap(page, 'login-flow-after-event');
|
||||
const meetingPointCreated = eventCreated ? await createMeetingPoint(page) : false;
|
||||
@@ -169,10 +141,12 @@ function classify(e: HttpError): string | null {
|
||||
const report = {
|
||||
user: USER,
|
||||
authenticated,
|
||||
login: login.detail,
|
||||
installed: install.actual,
|
||||
eventCreated,
|
||||
meetingPointCreated,
|
||||
redirects,
|
||||
hops,
|
||||
hops: drive.hops,
|
||||
benign4xx: benign,
|
||||
fatal4xx: fatal,
|
||||
consoleErrors,
|
||||
|
||||
+15
-53
@@ -24,7 +24,11 @@
|
||||
* nothing with that title reaches the pod).
|
||||
* 3. RECOVERED — signing in again from the picker goes through
|
||||
* `registerApp` → the consent screen; accepting it brings the user back to a
|
||||
* connected app whose probe reads current again, and a write lands.
|
||||
* connected app whose probe reads current again, and a write lands. The
|
||||
* pod's own session survived the app's logout, so this sign-in shows no pod
|
||||
* login form: the sign-in click leads straight to consent.
|
||||
*
|
||||
* Both sign-ins are the shared drive `driveLogin` (`e2e-rights-matrix-support.ts`).
|
||||
*
|
||||
* Nothing is stubbed: the probe result is whatever the pod answers. The probe
|
||||
* is triggered only through the app's real paths (page load, window focus,
|
||||
@@ -45,6 +49,7 @@ import {
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
} from '../src/shared/support/activitypodsStack';
|
||||
import { driveLogin, type DriveContext } from './e2e-rights-matrix-support';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
@@ -71,7 +76,8 @@ const errors4xx: HttpError[] = [];
|
||||
const writes: Array<{ phase: Phase; method: string; url: string; status: number }> = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const redirects: string[] = [];
|
||||
const hops: string[] = [];
|
||||
/** The shared sign-in drive's context (`driveLogin`): it writes the observed hops, tagged by phase; errors are collected by `wire` below. */
|
||||
const drive: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: DEADLINE };
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
@@ -123,48 +129,6 @@ const whereIs = async (page: Page): Promise<string> =>
|
||||
? (page.url().startsWith(`${POD_FRONT}/authorize`) ? 'consent' : `elsewhere:${clean(page.url())}`)
|
||||
: (await pickerShown(page)) ? 'picker' : 'still-connected';
|
||||
|
||||
/** Drive pod login → consent until the connected app shell is reached. */
|
||||
async function driveToApp(page: Page): Promise<boolean> {
|
||||
let authorizeSeen = 0;
|
||||
for (let i = 0; i < 25 && Date.now() < DEADLINE; i++) {
|
||||
const url = page.url();
|
||||
if (hops[hops.length - 1] !== `[${phase}] ${clean(url)}`) hops.push(`[${phase}] ${clean(url)}`);
|
||||
|
||||
if (url.startsWith(POD_FRONT) && (await page.locator('input[name="username"]').count().catch(() => 0))) {
|
||||
hops.push(` [${phase}] [login] submitting credentials`);
|
||||
await page.locator('input[name="username"]').fill(USER).catch(() => {});
|
||||
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
|
||||
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
if (await btn.count()) await btn.first().click().catch(() => {});
|
||||
else await page.locator('button[type=submit]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(3500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.startsWith(`${POD_FRONT}/authorize`)) {
|
||||
authorizeSeen++;
|
||||
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
const visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
hops.push(` [${phase}] [authorize #${authorizeSeen}] accept visible=${visible}`);
|
||||
await snap(page, `revocation-${phase}-consent`);
|
||||
if (visible) await acc.first().click().catch(() => {});
|
||||
if (authorizeSeen >= 3) { hops.push(` [${phase}] [LOOP] authorize revisited 3x`); return false; }
|
||||
await page.waitForTimeout(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (onApp(page)) {
|
||||
if (url.includes('/login')) { await page.waitForTimeout(3000); continue; }
|
||||
if (!(await pickerShown(page)) && !(await page.locator('#auth-reauthorizing').count().catch(() => 0))) {
|
||||
hops.push(` [${phase}] [app] shell reached`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The event-creation drive from `e2e-login-flow.ts`, reporting what the app showed. */
|
||||
async function createEvent(page: Page, title: string): Promise<{ eventUri?: string; successToast: boolean; finalUrl: string }> {
|
||||
let successToast = false;
|
||||
@@ -271,10 +235,9 @@ function benign(e: HttpError): boolean {
|
||||
wire(page);
|
||||
|
||||
// ── Phase 1: CURRENT ────────────────────────────────────────────────────
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
|
||||
const connected = await driveToApp(page);
|
||||
// The shared drive opens the app, clicks the pod's sign-in button and follows
|
||||
// the observed hops (pod login → consent → shell, bottom navigation shown).
|
||||
const connected = (await driveLogin(page, 'current', USER, PASS, drive)).ok;
|
||||
const probeRanCurrent = connected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'current' && s.status === 200 && isCurrent(s.body))
|
||||
&& probeLogs.some(l => l.phase === 'current' && /probe \(load\) → current/.test(l.text)),
|
||||
@@ -345,11 +308,10 @@ function benign(e: HttpError): boolean {
|
||||
const signInAgain = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
|
||||
let reconnected = false;
|
||||
if (leftAfterReloadTo === 'picker' && (await signInAgain.count().catch(() => 0))) {
|
||||
await signInAgain.first().click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
reconnected = await driveToApp(page);
|
||||
// The same drive: from the app's root (the picker, no session left), sign-in click → pod login → consent → shell.
|
||||
reconnected = (await driveLogin(page, 'recovered', USER, PASS, drive)).ok;
|
||||
}
|
||||
const consentReachedOnSignIn = hops.some(h => h.startsWith(' [recovered] [authorize'));
|
||||
const consentReachedOnSignIn = redirects.some(r => r.startsWith('[recovered]') && r.includes(`${clean(POD_FRONT)}/authorize`));
|
||||
const probeReadsCurrentAgain = reconnected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'recovered' && s.status === 200 && isCurrent(s.body)),
|
||||
15000,
|
||||
@@ -421,7 +383,7 @@ function benign(e: HttpError): boolean {
|
||||
revokedPhase4xx: errors4xx.filter(e => e.phase === 'revoked').map(e => `${e.status} ${e.method} ${clean(e.url)}`),
|
||||
fatal4xx: fatal,
|
||||
redirects,
|
||||
hops,
|
||||
hops: drive.hops,
|
||||
consoleErrors,
|
||||
ok: positive.ok && negative.ok && recovered.ok && fatal.length === 0,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* WHY NOT IMPORT THE BANKED SCRIPTS. `e2e-login-flow.ts` and `e2e-revocation.ts`
|
||||
* run their whole probe at import time (top-level IIFE) and their drives read
|
||||
* module-level user constants, so their helpers cannot be imported. The drives
|
||||
* below are the same sequences, parameterised by the user who drives.
|
||||
* below are the same sequences, parameterised by the user who drives. The other
|
||||
* direction works: `e2e-login-flow.ts` and `e2e-revocation.ts` import `driveLogin`
|
||||
* (the login-flow probe also the install check) from here, so the sign-in drive
|
||||
* exists once.
|
||||
*
|
||||
* NEVER A SILENT PASS. A line's check is a function returning `{ ok, actual }`;
|
||||
* a throw, a timeout or an unreachable host is recorded as FAIL with the error
|
||||
@@ -14,7 +17,7 @@
|
||||
* (`refused`), so a 404, a 500 or a network error (status 0) never counts as one.
|
||||
*/
|
||||
|
||||
import type { Page } from 'playwright';
|
||||
import type { Frame, Page } from 'playwright';
|
||||
import {
|
||||
linkId,
|
||||
podLogin,
|
||||
@@ -373,7 +376,10 @@ export interface LoginOutcome { ok: boolean; detail: string }
|
||||
* OBSERVED facts — never a fixed delay, never the mere absence of the picker.
|
||||
*
|
||||
* The real hop sequence (observed 2026-10-13): the sign-in click leaves the
|
||||
* app for the pod's login form (:5000/login); after the credentials the
|
||||
* app for the pod's login form (:5000/login) — unless the pod's session is
|
||||
* still open (a sign-out of the app keeps it: the revocation probe's second
|
||||
* sign-in), in which case no form is shown and the hops below start at once;
|
||||
* after the credentials the
|
||||
* browser comes BACK to the app's OIDC callback (:4000/login?code…), and only
|
||||
* then — when the app is not yet registered on this pod — does the app send it
|
||||
* to the pod's consent screen (:5000/authorize), which navigates a second time
|
||||
@@ -392,65 +398,82 @@ export async function driveLogin(page: Page, who: string, username: string, pass
|
||||
const t0 = Date.now();
|
||||
const at = (): string => `+${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
||||
const hop = (h: string): void => { ctx.hops.push(`[${who}] ${at()} ${h}`); };
|
||||
page.on('framenavigated', f => { if (f === page.mainFrame()) hop(`→ ${clean(f.url())}`); });
|
||||
const fail = async (why: string): Promise<LoginOutcome> => {
|
||||
const text = (await bodyText(page)).slice(0, 240);
|
||||
hop(`FAIL ${why} | at ${clean(page.url())} | page: ${text}`);
|
||||
return { ok: false, detail: `${why} (at ${clean(page.url())}; page: ${text.slice(0, 120)})` };
|
||||
};
|
||||
const onApp = (): boolean => page.url().startsWith(ctx.app);
|
||||
const accept = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
const consentReady = async (): Promise<boolean> =>
|
||||
page.url().startsWith(`${ctx.podFront}/authorize`)
|
||||
&& (await accept.first().isVisible().catch(() => false))
|
||||
&& (await accept.first().isEnabled().catch(() => false));
|
||||
const shellReady = async (): Promise<boolean> =>
|
||||
onApp() && !new URL(page.url()).pathname.startsWith('/login')
|
||||
&& (await page.getByText('Découvrir', { exact: true }).first().isVisible().catch(() => false))
|
||||
&& (await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 1)) === 0;
|
||||
// The hop log covers this drive only: the listener is removed when the drive ends, so a page
|
||||
// that signs in twice (the revocation probe) does not log its navigations twice.
|
||||
const onNavigated = (f: Frame): void => { if (f === page.mainFrame()) hop(`→ ${clean(f.url())}`); };
|
||||
page.on('framenavigated', onNavigated);
|
||||
try {
|
||||
const fail = async (why: string): Promise<LoginOutcome> => {
|
||||
const text = (await bodyText(page)).slice(0, 240);
|
||||
hop(`FAIL ${why} | at ${clean(page.url())} | page: ${text}`);
|
||||
return { ok: false, detail: `${why} (at ${clean(page.url())}; page: ${text.slice(0, 120)})` };
|
||||
};
|
||||
const onApp = (): boolean => page.url().startsWith(ctx.app);
|
||||
const accept = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
const consentReady = async (): Promise<boolean> =>
|
||||
page.url().startsWith(`${ctx.podFront}/authorize`)
|
||||
&& (await accept.first().isVisible().catch(() => false))
|
||||
&& (await accept.first().isEnabled().catch(() => false));
|
||||
const shellReady = async (): Promise<boolean> =>
|
||||
onApp() && !new URL(page.url()).pathname.startsWith('/login')
|
||||
&& (await page.getByText('Découvrir', { exact: true }).first().isVisible().catch(() => false))
|
||||
&& (await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 1)) === 0;
|
||||
|
||||
await page.goto(`${ctx.app}/`, { waitUntil: 'domcontentloaded' });
|
||||
const signIn = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
|
||||
if (!(await signIn.first().waitFor({ state: 'visible', timeout: 30000 }).then(() => true, () => false))) return fail('the sign-in button never appeared');
|
||||
await signIn.first().click();
|
||||
if (!(await page.waitForURL(u => !u.href.startsWith(ctx.app), { timeout: 30000 }).then(() => true, () => false))) {
|
||||
return fail('the sign-in click never left the app for the pod');
|
||||
}
|
||||
|
||||
const userField = page.locator('input[name="username"]');
|
||||
if (!(await userField.waitFor({ state: 'visible', timeout: 30000 }).then(() => true, () => false))) return fail('the pod login form never appeared');
|
||||
await userField.fill(username);
|
||||
await page.locator('input[name="password"]').fill(password);
|
||||
const submit = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
await ((await submit.count()) ? submit.first() : page.locator('button[type=submit]').first()).click();
|
||||
hop('credentials submitted');
|
||||
|
||||
// After login: the consent screen (app not yet registered here) or straight to the shell (already registered).
|
||||
const next = await poll(
|
||||
async () => ((await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
|
||||
v => v !== 'pending', 60000, 250,
|
||||
);
|
||||
if (next === 'pending') return fail('after login, neither the consent screen nor the app shell appeared');
|
||||
if (next === 'consent') {
|
||||
// Stable for a full second (4 consecutive observations): the second navigation of the page is over.
|
||||
let stable = 0;
|
||||
const settleEnd = Date.now() + 15000;
|
||||
while (stable < 4 && Date.now() < settleEnd) {
|
||||
stable = (await consentReady()) ? stable + 1 : 0;
|
||||
await page.waitForTimeout(250);
|
||||
await page.goto(`${ctx.app}/`, { waitUntil: 'domcontentloaded' });
|
||||
const signIn = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
|
||||
if (!(await signIn.first().waitFor({ state: 'visible', timeout: 30000 }).then(() => true, () => false))) return fail('the sign-in button never appeared');
|
||||
await signIn.first().click();
|
||||
if (!(await page.waitForURL(u => !u.href.startsWith(ctx.app), { timeout: 30000 }).then(() => true, () => false))) {
|
||||
return fail('the sign-in click never left the app for the pod');
|
||||
}
|
||||
if (stable < 4) return fail('the consent Accept button never stayed visible and enabled');
|
||||
hop('consent ready (Accept attached, visible, enabled for 1 s)');
|
||||
await accept.first().click();
|
||||
hop('accept clicked');
|
||||
if (!(await page.waitForURL(u => !u.href.startsWith(`${ctx.podFront}/authorize`), { timeout: 30000 }).then(() => true, () => false))) {
|
||||
return fail('Accept was clicked but the page never left the consent screen');
|
||||
|
||||
// At the pod: its login form when no pod session is open — or, when one still is (signing out
|
||||
// of the APP keeps the pod's session), no form at all: straight on to consent or to the shell.
|
||||
const userField = page.locator('input[name="username"]');
|
||||
const atPod = await poll(
|
||||
async () => ((await userField.isVisible().catch(() => false)) ? 'login' : (await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
|
||||
v => v !== 'pending', 30000, 250,
|
||||
);
|
||||
if (atPod === 'pending') return fail('after the sign-in click, neither the pod login form, the consent screen nor the app shell appeared');
|
||||
if (atPod === 'login') {
|
||||
await userField.fill(username);
|
||||
await page.locator('input[name="password"]').fill(password);
|
||||
const submit = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
await ((await submit.count()) ? submit.first() : page.locator('button[type=submit]').first()).click();
|
||||
hop('credentials submitted');
|
||||
} else {
|
||||
hop(`no pod login asked (the pod session is still open) → ${atPod}`);
|
||||
}
|
||||
hop('left the consent screen');
|
||||
if ((await poll(shellReady, v => v, 45000, 250)) !== true) return fail('back from consent, the app shell never appeared');
|
||||
|
||||
// After login: the consent screen (app not yet registered here) or straight to the shell (already registered).
|
||||
const next = await poll(
|
||||
async () => ((await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
|
||||
v => v !== 'pending', 60000, 250,
|
||||
);
|
||||
if (next === 'pending') return fail('after login, neither the consent screen nor the app shell appeared');
|
||||
if (next === 'consent') {
|
||||
// Stable for a full second (4 consecutive observations): the second navigation of the page is over.
|
||||
let stable = 0;
|
||||
const settleEnd = Date.now() + 15000;
|
||||
while (stable < 4 && Date.now() < settleEnd) {
|
||||
stable = (await consentReady()) ? stable + 1 : 0;
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
if (stable < 4) return fail('the consent Accept button never stayed visible and enabled');
|
||||
hop('consent ready (Accept attached, visible, enabled for 1 s)');
|
||||
await accept.first().click();
|
||||
hop('accept clicked');
|
||||
if (!(await page.waitForURL(u => !u.href.startsWith(`${ctx.podFront}/authorize`), { timeout: 30000 }).then(() => true, () => false))) {
|
||||
return fail('Accept was clicked but the page never left the consent screen');
|
||||
}
|
||||
hop('left the consent screen');
|
||||
if ((await poll(shellReady, v => v, 45000, 250)) !== true) return fail('back from consent, the app shell never appeared');
|
||||
}
|
||||
hop(`shell reached (bottom navigation, no picker) via ${next === 'consent' ? 'consent' : 'an existing registration'}`);
|
||||
return { ok: true, detail: `shell reached in ${at()} via ${next === 'consent' ? 'the consent screen' : 'an existing registration (no consent asked)'}` };
|
||||
} finally {
|
||||
page.off('framenavigated', onNavigated);
|
||||
}
|
||||
hop(`shell reached (bottom navigation, no picker) via ${next === 'consent' ? 'consent' : 'an existing registration'}`);
|
||||
return { ok: true, detail: `shell reached in ${at()} via ${next === 'consent' ? 'the consent screen' : 'an existing registration (no consent asked)'}` };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,20 @@
|
||||
* profile; a twin event WITH public intent is readable and listed; Bob reads
|
||||
* Alice's profile only after her join; the owner's own container POST lands).
|
||||
*
|
||||
* BACKEND PUBLIC SURFACE (T05.i allow-list). Anonymous GETs only, status codes
|
||||
* only — never a body (before 88f23b7, `/key` served the app's private key):
|
||||
* every allow-listed endpoint answers 200 (the control), a fixed list of
|
||||
* non-public paths answers 404, the gate's refusal for every caller.
|
||||
*
|
||||
* VISIBILITY FOLLOWS THE OWNER (T05.i). An event V with a meeting point VM and
|
||||
* a participant (Carol joins through her own outbox, as the app does) is
|
||||
* switched private → public → private by Bob PATCHing `to as:Public` on his
|
||||
* own two resources — a legitimate owner update (no UI for it yet). At every
|
||||
* state: anonymous reads of V, VM and both attendees collections, the catalog
|
||||
* listing, and Carol's membership (only the public read moves). For a
|
||||
* collection the pod's refusal is 404 (VERIFIED 2026-10-13); it counts as one
|
||||
* only next to Bob's 200 on the same URI at the same moment.
|
||||
*
|
||||
* Lines are independent: a failure is recorded and the run continues; a line
|
||||
* whose prerequisite is missing FAILs with the name of what is missing.
|
||||
*
|
||||
@@ -50,6 +64,7 @@ import { chromium, type Browser, type Page } from 'playwright';
|
||||
import {
|
||||
attendeesCollectionUri,
|
||||
linkId,
|
||||
postOutboxActivity,
|
||||
recordUri,
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
@@ -99,6 +114,8 @@ const TITLE_M = `Rdv matrice ${TS}`;
|
||||
const TITLE_M2 = `Rdv second ${TS}`;
|
||||
const TITLE_P = `Prive matrice ${TS}`;
|
||||
const TITLE_T = `Public jumeau ${TS}`;
|
||||
const TITLE_V = `Visibilite matrice ${TS}`;
|
||||
const TITLE_VM = `Rdv visibilite ${TS}`;
|
||||
|
||||
const PLATFORM_CONTEXT = 'https://activitypods.org/context.json';
|
||||
const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
@@ -113,6 +130,10 @@ const S: {
|
||||
bob?: ApodsSession; alice?: ApodsSession; carol?: ApodsSession;
|
||||
/** M and its attendees A are the first meeting point; M2 / A2 the second. */
|
||||
E?: string; M?: string; A?: string; M2?: string; A2?: string; P?: string; T?: string;
|
||||
/** The visibility group's event, its meeting point, and their attendees collections. */
|
||||
V?: string; VM?: string; AV?: string; AVM?: string;
|
||||
/** Set only by the line that SAW Carol's WebID land in VM's collection. */
|
||||
carolInVM?: boolean;
|
||||
bobProfile?: string; aliceProfile?: string; carolProfile?: string;
|
||||
/** Each user's own preferences file, keyed by the UI drive's tag (bob/alice/carol). */
|
||||
preferences: Map<string, string>;
|
||||
@@ -747,6 +768,160 @@ async function tracked(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend's public surface: the allow-list of `backend/services/public-surface.js`
|
||||
* (T05.i). Anonymous GETs, STATUS CODES ONLY — no body is ever printed or kept.
|
||||
*/
|
||||
async function publicSurface(): Promise<void> {
|
||||
const G = 'backend public surface (anonymous GET, status codes only)';
|
||||
const status = async (path: string): Promise<number> => (await http('GET', `${BACKEND}${path}`)).status;
|
||||
const pathOf = (uri: string | undefined, what: string): string => {
|
||||
const u = need(uri, what);
|
||||
if (!u.startsWith(`${BACKEND}/`)) throw new Error(`${what} is not on the backend (${u})`);
|
||||
return u.slice(BACKEND.length);
|
||||
};
|
||||
const shown = (path: string): string => path.replace(/\/[A-Za-z0-9_-]{16,}$/, '/<id>');
|
||||
await m.hard('surface.public-200', G,
|
||||
'CONTROL: every allow-listed public endpoint answers an anonymous GET',
|
||||
'200 each: /app, /as/actor/app, /catalog, /public-key/<id>, /interop/access-need-group/<id>, /interop/access-need/<id>, /.well-known/context.jsonld, /.well-known/nodeinfo, /nodeinfo/2.1',
|
||||
async () => {
|
||||
const actor = parseJson((await http('GET', `${BACKEND}/as/actor/app`)).text);
|
||||
const app = parseJson((await http('GET', `${BACKEND}/app`)).text);
|
||||
const groupPath = pathOf(linkId(app?.['interop:hasAccessNeedGroup']), 'the access-need group (GET /app)');
|
||||
const group = parseJson((await http('GET', `${BACKEND}${groupPath}`)).text);
|
||||
const paths = [
|
||||
'/app', '/as/actor/app', '/catalog',
|
||||
pathOf(linkId(actor?.['publicKey']), 'the app actor\'s public key (GET /as/actor/app)'),
|
||||
groupPath,
|
||||
pathOf(linkId(arrayOf(group?.['interop:hasAccessNeed'])[0]), 'an access need (the access-need group)'),
|
||||
'/.well-known/context.jsonld', '/.well-known/nodeinfo', '/nodeinfo/2.1',
|
||||
];
|
||||
const codes = await Promise.all(paths.map(status));
|
||||
return { ok: codes.every(c => c === 200), actual: paths.map((p, i) => `${shown(p)} ${codes[i]}`).join(', ') };
|
||||
});
|
||||
const webfinger = `/.well-known/webfinger?resource=${encodeURIComponent(`acct:app@${new URL(BACKEND).host}`)}`;
|
||||
const refusedPaths = [
|
||||
'/', '/key', '/sparql', '/sparql?query=ASK%7B%7D', '/settings', '/proxy', '/_acl', '/.webhooks',
|
||||
'/interop/application-registration', '/interop/access-grant', '/interop/data-grant',
|
||||
'/as/actor/app/outbox', '/as/actor/app/followers', webfinger,
|
||||
];
|
||||
await m.hard('surface.non-public-404', G,
|
||||
'anonymous GET on non-public backend paths (private keys, SPARQL, listings, registrations and grants, the app actor\'s collections, webfinger)',
|
||||
'404 each — the gate\'s refusal for every caller (control: surface.public-200 on the same host)', async () => {
|
||||
const codes = await Promise.all(refusedPaths.map(status));
|
||||
return { ok: codes.every(c => c === 404), actual: refusedPaths.map((p, i) => `${p} ${codes[i]}`).join(', ') };
|
||||
});
|
||||
m.manual('manual.surface-writes', G,
|
||||
'writes on the backend outside its two delivery endpoints are refused (404), and a crafted delivery to POST /as/actor/app/inbox or /.webhooks/<id> is never trusted',
|
||||
'NOT exercised live (read-only sweeps only, user decision 2026-10-13). Covered by backend unit tests — '
|
||||
+ 'public-surface.test.js (every write outside the two delivery endpoints refused; the LDP catch-all behind the gate) and '
|
||||
+ 'emitted-activity.test.js (activity and object re-read from the pod before any decision).');
|
||||
}
|
||||
|
||||
/** Bob's view of a resource's attendees collection link, polled (the backend attaches it asynchronously). */
|
||||
async function collectionOf(uri: string): Promise<string | undefined> {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
return poll(async () => attendeesCollectionUri(parseJson((await http('GET', uri, { token: bob.token })).text) ?? {}), v => !!v, 30000);
|
||||
}
|
||||
|
||||
/** Bob POSTs an event the way Festipod creates one (container POST, `to: as:Public`). */
|
||||
async function postPublicEvent(name: string, extra: Record<string, unknown> = {}): Promise<{ uri?: string; status: number }> {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const r = await http('POST', `${bob.storage}/as/event`, {
|
||||
token: bob.token, contentType: 'application/ld+json',
|
||||
body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name, startTime: '2026-12-22T18:00:00Z', endTime: '2026-12-22T22:00:00Z', to: AS_PUBLIC, ...extra }),
|
||||
});
|
||||
return { uri: r.headers.get('Location') ?? undefined, status: r.status };
|
||||
}
|
||||
|
||||
async function visibility(): Promise<void> {
|
||||
const G = 'visibility follows the owner (V + VM + participant)';
|
||||
await m.hard('visibility.setup-V', G, `Bob creates event V "${TITLE_V}" and its meeting point VM "${TITLE_VM}" as Festipod does (container POST, \`to: as:Public\`)`, '201 + Location for each', async () => {
|
||||
const v = await postPublicEvent(TITLE_V);
|
||||
S.V = v.uri;
|
||||
const vm = S.V ? await postPublicEvent(TITLE_VM, { 'as:context': S.V }) : { status: 0, uri: undefined };
|
||||
S.VM = vm.uri;
|
||||
return { ok: !!S.V && !!S.VM, actual: `V: HTTP ${v.status} → ${S.V ?? '(no Location)'}; VM: HTTP ${vm.status} → ${S.VM ?? '(no Location)'}` };
|
||||
});
|
||||
await m.hard('visibility.setup-collections', G, 'V and VM each carry their apods:attendees collection', 'two distinct collection URIs within 30 s', async () => {
|
||||
S.AV = await collectionOf(need(S.V, 'event V'));
|
||||
S.AVM = await collectionOf(need(S.VM, 'meeting point VM'));
|
||||
return { ok: !!S.AV && !!S.AVM && S.AV !== S.AVM, actual: `V: ${S.AV ?? '(none)'}; VM: ${S.AVM ?? '(none)'}` };
|
||||
});
|
||||
await m.hard('visibility.setup-join', G, "Carol joins VM through her own outbox (Join addressed to Bob, as the app does)", "Carol's WebID in VM's attendees items within 30 s (read by Bob)", async () => {
|
||||
const carol = need(S.carol, 'Carol account');
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const AVM = need(S.AVM, "VM's attendees collection");
|
||||
await postOutboxActivity(carol, 'Join', need(S.VM, 'meeting point VM'), bob.webId);
|
||||
const a = await poll(() => attendees(AVM, bob.token), v => v.status === 200 && v.items.includes(carol.webId), 30000);
|
||||
S.carolInVM = a.status === 200 && a.items.includes(carol.webId);
|
||||
return { ok: S.carolInVM, actual: `HTTP ${a.status} items=${JSON.stringify(a.items)}` };
|
||||
});
|
||||
|
||||
const AS_TO = 'https://www.w3.org/ns/activitystreams#to';
|
||||
const setPublic = async (uri: string, on: boolean): Promise<number> =>
|
||||
(await http('PATCH', uri, { token: need(S.bob, 'Bob account').token, contentType: 'application/sparql-update', body: `${on ? 'INSERT' : 'DELETE'} DATA { <${uri}> <${AS_TO}> <${AS_PUBLIC}> . }` })).status;
|
||||
|
||||
type Read = { label: string; uri: string; anon: number; bob?: number };
|
||||
/** One observation of the four resources: anonymous status, plus Bob's for the collections (the 404 control). */
|
||||
const observe = async (): Promise<Read[]> => {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const targets: Array<[string, string, boolean]> = [
|
||||
['V', need(S.V, 'event V'), false], ['VM', need(S.VM, 'meeting point VM'), false],
|
||||
["VM's collection", need(S.AVM, "VM's attendees collection"), true], ["V's collection", need(S.AV, "V's attendees collection"), true],
|
||||
];
|
||||
return Promise.all(targets.map(async ([label, uri, isCollection]) => ({
|
||||
label, uri,
|
||||
anon: (await http('GET', uri)).status,
|
||||
bob: isCollection ? (await http('GET', uri, { token: bob.token })).status : undefined,
|
||||
})));
|
||||
};
|
||||
const readable = (r: Read): boolean => r.anon === 200;
|
||||
/** A refusal: 401/403; for a collection also the pod's 404, but only while Bob reads the same URI 200. */
|
||||
const closed = (r: Read): boolean => refused(r.anon, ANON) || (r.bob !== undefined && r.anon === 404 && r.bob === 200);
|
||||
const show = (rs: Read[]): string => rs.map(r => `${r.label} anon ${r.anon}${r.bob !== undefined ? ` (Bob ${r.bob})` : ''}`).join(', ');
|
||||
|
||||
const states: Array<{ id: string; label: string; on: boolean; toggle: boolean }> = [
|
||||
{ id: 'created-public', label: 'as created (public)', on: true, toggle: false },
|
||||
{ id: 'private-1', label: 'switched private', on: false, toggle: true },
|
||||
{ id: 'public-again', label: 'switched public again', on: true, toggle: true },
|
||||
{ id: 'private-2', label: 'switched private again', on: false, toggle: true },
|
||||
];
|
||||
for (const st of states) {
|
||||
if (st.toggle) {
|
||||
await m.hard(`visibility.${st.id}-update`, G, `Bob ${st.on ? 'adds' : 'removes'} \`to as:Public\` on V and on VM (PATCH, his own token)`, '2xx for both', async () => {
|
||||
const a = await setPublic(need(S.V, 'event V'), st.on);
|
||||
const b = await setPublic(need(S.VM, 'meeting point VM'), st.on);
|
||||
return { ok: a >= 200 && a < 300 && b >= 200 && b < 300, actual: `V PATCH ${a}, VM PATCH ${b}` };
|
||||
});
|
||||
}
|
||||
await m.hard(`visibility.${st.id}-reads`, G,
|
||||
`${st.label}: anonymous reads of V, VM, VM's collection, V's collection`,
|
||||
st.on ? '200 for all four within 45 s' : 'V and VM refused 401/403; each collection refused (401/403, or the pod\'s 404 while Bob reads it 200) within 45 s',
|
||||
async () => {
|
||||
const want = st.on ? readable : closed;
|
||||
const rs = await poll(observe, v => v.every(want), 45000, 1500);
|
||||
return { ok: rs.every(want), actual: show(rs) };
|
||||
});
|
||||
await m.hard(`visibility.${st.id}-catalog`, G, `${st.label}: V and VM ${st.on ? 'listed in' : 'absent from'} GET /catalog`, `${st.on ? 'both listed' : 'neither listed'} within 45 s`, async () => {
|
||||
const V = need(S.V, 'event V');
|
||||
const VM = need(S.VM, 'meeting point VM');
|
||||
const c = await poll(() => catalogIds(), v => v.ids.includes(V) === st.on && v.ids.includes(VM) === st.on, 45000, 1500);
|
||||
return { ok: c.ids.includes(V) === st.on && c.ids.includes(VM) === st.on, actual: `V ${c.ids.includes(V) ? 'listed' : 'absent'}, VM ${c.ids.includes(VM) ? 'listed' : 'absent'}` };
|
||||
});
|
||||
await m.hard(`visibility.${st.id}-member`, G, `${st.label}: Carol is still a member of VM (only the public read moved)`, "Carol's WebID in VM's items (read by Bob, 200)", async () => {
|
||||
need(S.carolInVM, 'the join on VM landed (visibility.setup-join)');
|
||||
const carol = need(S.carol, 'Carol account');
|
||||
const a = await attendees(need(S.AVM, "VM's attendees collection"), need(S.bob, 'Bob account').token);
|
||||
return { ok: a.status === 200 && a.items.includes(carol.webId), actual: `HTTP ${a.status} items=${JSON.stringify(a.items)}` };
|
||||
});
|
||||
}
|
||||
m.manual('manual.visibility-crafted', G,
|
||||
'a webhook or activity CLAIMING a visibility change (not made on the pod) never grants, revokes, lists or de-lists',
|
||||
'NOT exercised live (no forged activity, user decision 2026-10-13). Covered by backend unit tests — emitted-activity.test.js and '
|
||||
+ 'public-read.service.test.js / catalog.service.test.js (decisions taken on the object re-read from the pod, under its per-object lock).');
|
||||
}
|
||||
|
||||
async function uiHygiene(): Promise<void> {
|
||||
const G = 'UI drives';
|
||||
// Expected refusals are exact URLs: this run's profile documents, and each
|
||||
@@ -787,6 +962,7 @@ async function uiHygiene(): Promise<void> {
|
||||
// cross-origin rules, as it does for a user.
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
await publicSurface();
|
||||
await setup(browser);
|
||||
await publicReads();
|
||||
await writesRefused();
|
||||
@@ -800,12 +976,13 @@ async function uiHygiene(): Promise<void> {
|
||||
await leave();
|
||||
await deletesRefused();
|
||||
await tracked();
|
||||
await visibility();
|
||||
} finally {
|
||||
await uiHygiene();
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
const file = joinPath(tmpdir(), `rights-matrix-${TS}.json`);
|
||||
writeFileSync(file, JSON.stringify({ run: TS, users: USERS, ids: { E: S.E, M: S.M, A: S.A, M2: S.M2, A2: S.A2, P: S.P, T: S.T }, lines: m.lines, hops: ui.hops, uiErrors: ui.errors, pageErrors: ui.pageErrors, consoleErrors: ui.consoleErrors }, null, 2));
|
||||
writeFileSync(file, JSON.stringify({ run: TS, users: USERS, ids: { E: S.E, M: S.M, A: S.A, M2: S.M2, A2: S.A2, P: S.P, T: S.T, V: S.V, VM: S.VM, AV: S.AV, AVM: S.AVM }, lines: m.lines, hops: ui.hops, uiErrors: ui.errors, pageErrors: ui.pageErrors, consoleErrors: ui.consoleErrors }, null, 2));
|
||||
m.print(`run ${TS} (bob=${USERS.bob}, alice=${USERS.alice}, carol=${USERS.carol}) — JSON: ${file}`);
|
||||
process.exit(m.hardFailures.length === 0 ? 0 : 1);
|
||||
})().catch(err => {
|
||||
|
||||
+139
-17
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* validate.ts — the Festipod validation matrix.
|
||||
*
|
||||
* SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in
|
||||
* the gate:
|
||||
* SCOPE (2026-10-09, after the ActivityPods-first switch; e2e probes added
|
||||
* 2026-10-13). Four layers are in the gate:
|
||||
*
|
||||
* (a) Festipod @ui — the UI layer. The `@ui` tag does not exist in this repo;
|
||||
* the layer is exactly the complement of the polyfill/broker tags, and its
|
||||
@@ -23,6 +23,25 @@
|
||||
* Requires the stack to be UP (the app server and Chromium are started
|
||||
* here); an unreachable stack fails the layer rather than skipping it.
|
||||
*
|
||||
* (c) Festipod e2e rights matrix — `bun run test:e2e-rights`: three fresh
|
||||
* users plus anonymous, every feature with positive AND negative access
|
||||
* lines (public surface, public reads, refused writes, profile privacy,
|
||||
* catalog and public intent, discovery, join/leave and counters,
|
||||
* visibility toggles). Exit 0 only when every hard line holds.
|
||||
*
|
||||
* (d) Festipod e2e revocation — `bun run test:e2e-revocation`: the app
|
||||
* notices its registration being revoked on the pod, logs the user out,
|
||||
* and recovers through consent.
|
||||
*
|
||||
* (c) and (d) drive the browser through the pod's login and consent, so
|
||||
* they need the WHOLE local stack, the app served at APP_URL included
|
||||
* (the OIDC redirect_uri is registered for that origin only). They are
|
||||
* STACK-GATED: the stack is probed first; when any part is unreachable
|
||||
* both are SKIPPED — loudly, the unreachable URL named in the matrix and
|
||||
* in the verdict, never silently — and when it is up they run, and a
|
||||
* non-zero exit fails the layer. A fully-down stack still fails
|
||||
* `validate` through (b), which never skips.
|
||||
*
|
||||
* SUSPENDED, NOT RUN HERE (documented skip, code preserved — nothing deleted):
|
||||
* - `@data`, `@e2e`, `@multibrowser`, `@shared-wallet`, `@smoke` scenarios are
|
||||
* polyfill/broker-bound: they sign the shared NextGraph wallet through the
|
||||
@@ -34,7 +53,8 @@
|
||||
* `rule_tests-validate-festipod-not-the-sdk`).
|
||||
*
|
||||
* A smoke layer that cannot reach its stack FAILS, loudly — it never silently
|
||||
* skips. Exit code is non-zero if any layer fails.
|
||||
* skips. A skipped e2e probe never counts as passed: it is reported as
|
||||
* SKIPPED with its reason. Exit code is non-zero if any layer fails.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
@@ -51,12 +71,34 @@ const APODS_TAGS = "@e2e-activitypods and not @suspendu";
|
||||
|
||||
const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min
|
||||
const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_RIGHTS_MS = 25 * 60 * 1000; // 25 min (the matrix bounds itself at 15 min of UI drives)
|
||||
const TIMEOUT_REVOCATION_MS = 15 * 60 * 1000; // 15 min (the probe bounds itself at 10 min)
|
||||
|
||||
/** The local stack, with the same defaults and env overrides as the e2e scripts. */
|
||||
const strip = (u: string): string => u.replace(/\/+$/, "");
|
||||
const POD_PROVIDER = strip(process.env.POD_PROVIDER_URL || "http://localhost:3000");
|
||||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || "http://localhost:5000");
|
||||
const BACKEND = strip(process.env.BACKEND_URL || "http://localhost:3003");
|
||||
const APP = strip(process.env.APP_URL || "http://localhost:4000");
|
||||
const FUSEKI = strip(process.env.FUSEKI_URL || "http://localhost:3030");
|
||||
|
||||
/** What the browser-driven e2e probes need answering 200 — every piece of the local stack. */
|
||||
const E2E_STACK: Array<[string, string]> = [
|
||||
// The triplestore both servers store into: with it down they still answer their static routes
|
||||
// (observed 2026-10-13: the pod's OpenID configuration 200 while every sign-up failed).
|
||||
["triplestore (fuseki)", `${FUSEKI}/`],
|
||||
["pod provider", `${POD_PROVIDER}/.well-known/openid-configuration`],
|
||||
["pod frontend (login + consent)", `${POD_FRONT}/`],
|
||||
["Festipod backend", `${BACKEND}/app`],
|
||||
["Festipod app at APP_URL (the only registered OIDC redirect origin)", `${APP}/`],
|
||||
];
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
status: "passed" | "failed" | "error" | "skipped";
|
||||
/** Failure lines; for a skipped layer, why it was skipped. */
|
||||
failures: string[];
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
@@ -75,14 +117,20 @@ function cucumberArgs(config: string, tags: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function runStep(label: string, args: string[], timeoutMs: number): StepResult {
|
||||
function runStep(
|
||||
label: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
command = "node",
|
||||
extract: (output: string) => string[] = extractFailures,
|
||||
): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(64)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` node ${args.join(" ")}`);
|
||||
console.log(` ${command} ${args.join(" ")}`);
|
||||
console.log(`${"═".repeat(64)}`);
|
||||
|
||||
const result = spawnSync("node", args, {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: REPO_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf-8",
|
||||
@@ -107,12 +155,68 @@ function runStep(label: string, args: string[], timeoutMs: number): StepResult {
|
||||
return {
|
||||
label,
|
||||
status: (result.status ?? 1) === 0 ? "passed" : "failed",
|
||||
failures: extractFailures(combined),
|
||||
failures: extract(combined),
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** A stack-gated layer that did not run: reported, never counted as passed. */
|
||||
function skippedStep(label: string, reasons: string[]): StepResult {
|
||||
console.log(`\n${"═".repeat(64)}`);
|
||||
console.log(`⏭️ ${label} — SKIPPED: the local stack is down`);
|
||||
for (const r of reasons) console.log(` ↳ ${r}`);
|
||||
console.log(`${"═".repeat(64)}`);
|
||||
return { label, status: "skipped", failures: reasons, exitCode: 0, durationMs: 0 };
|
||||
}
|
||||
|
||||
/** Every part of the e2e stack that does not answer 200, as "name: url → status". Empty when the stack is up. */
|
||||
async function e2eStackDown(): Promise<string[]> {
|
||||
const down: string[] = [];
|
||||
for (const [name, url] of E2E_STACK) {
|
||||
let status: number | string;
|
||||
try {
|
||||
status = (await fetch(url, { signal: AbortSignal.timeout(5000) })).status;
|
||||
} catch (e) {
|
||||
status = `unreachable (${e instanceof Error ? e.message : String(e)})`;
|
||||
}
|
||||
if (status !== 200) down.push(`${name}: ${url} → ${typeof status === "number" ? `HTTP ${status}` : status}`);
|
||||
}
|
||||
return down;
|
||||
}
|
||||
|
||||
/** The rights matrix's verdict lines: every failed hard line, any notice, and the verdict. */
|
||||
function extractMatrixFailures(output: string): string[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^(FAILED: |VERDICT: FAIL|!!! (?!NOTICE:)\S)/.test(l) || l.includes("RIGHTS MATRIX — ABORTED"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The revocation probe's JSON report (pretty-printed: its top-level braces are
|
||||
* the only ones at column 0): the phases that did not hold, and unexpected ≥400s.
|
||||
*/
|
||||
function extractRevocationFailures(output: string): string[] {
|
||||
const start = output.lastIndexOf("\n{\n") + 1;
|
||||
const end = output.indexOf("\n}", start);
|
||||
try {
|
||||
const report = JSON.parse(output.slice(start, end + 2)) as Record<string, unknown>;
|
||||
if (report["ok"] === true) return [];
|
||||
const failures: string[] = [];
|
||||
for (const phase of ["positive", "negative", "recovered"]) {
|
||||
const p = report[phase] as { ok?: unknown } | undefined;
|
||||
if (p && p.ok !== true) failures.push(`phase ${phase}: ok=${String(p.ok)}`);
|
||||
}
|
||||
const fatal = report["fatal4xx"];
|
||||
if (Array.isArray(fatal) && fatal.length) failures.push(`${fatal.length} unexpected ≥400 response(s)`);
|
||||
if (typeof report["fatal"] === "string") failures.push(`aborted: ${report["fatal"]}`);
|
||||
return failures.length ? failures : ["ok=false"];
|
||||
} catch {
|
||||
return ["no JSON report found in the output"];
|
||||
}
|
||||
}
|
||||
|
||||
/** The failure lines worth surfacing: failed scenario names and the summary. */
|
||||
function extractFailures(output: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
@@ -139,31 +243,49 @@ function printMatrix(steps: StepResult[]): void {
|
||||
console.log("╚══════════════════════════════════════════════════════════╝\n");
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`);
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : step.status === "skipped" ? "⏭️ " : "⚠️ ";
|
||||
const time = step.status === "skipped" ? "SKIPPED — local stack down" : fmtDuration(step.durationMs);
|
||||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${time}]`);
|
||||
for (const f of step.failures) console.log(` ↳ ${f}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(" ℹ️ @data/@e2e/@multibrowser/@shared-wallet/@smoke : suspendus (workstream NextGraph).");
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(failed.length === 0
|
||||
? " 🟢 ALL LAYERS PASSED"
|
||||
: ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
const failed = steps.filter((s) => s.status === "failed" || s.status === "error");
|
||||
const skipped = steps.filter((s) => s.status === "skipped");
|
||||
if (skipped.length) {
|
||||
console.log(` 🟡 ${skipped.length} LAYER(S) SKIPPED (local stack down — NOT validated): ${skipped.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(failed.length > 0
|
||||
? ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`
|
||||
: skipped.length
|
||||
? " 🟢 NO LAYER FAILED — but the skipped layers above were not run"
|
||||
: " 🟢 ALL LAYERS PASSED");
|
||||
console.log(` ⏱ Total: ${fmtDuration(steps.reduce((sum, s) => sum + s.durationMs, 0))}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function main(): void {
|
||||
async function main(): Promise<void> {
|
||||
console.log(`🔍 Festipod — validation (repo: ${REPO_DIR})`);
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
steps.push(runStep("festipod:@ui", cucumberArgs("cucumber.json", UI_TAGS), TIMEOUT_UI_MS));
|
||||
steps.push(runStep("festipod:@e2e-activitypods", cucumberArgs("cucumber.smoke.json", APODS_TAGS), TIMEOUT_APODS_MS));
|
||||
|
||||
// Stack-gated e2e probes: one stack probe, then both run or both are skipped (loudly).
|
||||
const down = await e2eStackDown();
|
||||
if (down.length) {
|
||||
const reasons = [...down, "start the stack: concept `data-layer`, `cookbook_local-activitypods-platform.md`"];
|
||||
steps.push(skippedStep("festipod:e2e-rights", reasons));
|
||||
steps.push(skippedStep("festipod:e2e-revocation", reasons));
|
||||
} else {
|
||||
steps.push(runStep("festipod:e2e-rights", ["run", "test:e2e-rights"], TIMEOUT_RIGHTS_MS, "bun", extractMatrixFailures));
|
||||
steps.push(runStep("festipod:e2e-revocation", ["run", "test:e2e-revocation"], TIMEOUT_REVOCATION_MS, "bun", extractRevocationFailures));
|
||||
}
|
||||
|
||||
printMatrix(steps);
|
||||
process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0);
|
||||
process.exit(steps.some((s) => s.status === "failed" || s.status === "error") ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
void main();
|
||||
|
||||
Reference in New Issue
Block a user