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:
Sylvain Duchesne
2026-10-13 18:11:34 +02:00
parent 88f23b7023
commit 9381c206df
5 changed files with 437 additions and 179 deletions
+24 -50
View File
@@ -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
View File
@@ -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,
};
+81 -58
View File
@@ -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)'}` };
}
/**
+178 -1
View File
@@ -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
View File
@@ -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();