From 9381c206dfb0ac878bfd4d16c6ed5367324cbcd2 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 13 Oct 2026 18:11:34 +0200 Subject: [PATCH] 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. --- scripts/e2e-login-flow.ts | 74 ++++------- scripts/e2e-revocation.ts | 68 +++------- scripts/e2e-rights-matrix-support.ts | 139 ++++++++++++--------- scripts/e2e-rights-matrix.ts | 179 ++++++++++++++++++++++++++- scripts/validate.ts | 156 ++++++++++++++++++++--- 5 files changed, 437 insertions(+), 179 deletions(-) diff --git a/scripts/e2e-login-flow.ts b/scripts/e2e-login-flow.ts index 3aea7cf..4b3d0eb 100644 --- a/scripts/e2e-login-flow.ts +++ b/scripts/e2e-login-flow.ts @@ -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 => page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim()); const snap = (page: Page, name: string): Promise => @@ -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 { - 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 { 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, diff --git a/scripts/e2e-revocation.ts b/scripts/e2e-revocation.ts index fa51009..54bf170 100644 --- a/scripts/e2e-revocation.ts +++ b/scripts/e2e-revocation.ts @@ -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 => @@ -123,48 +129,6 @@ const whereIs = async (page: Page): Promise => ? (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 { - 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, }; diff --git a/scripts/e2e-rights-matrix-support.ts b/scripts/e2e-rights-matrix-support.ts index a575366..9f6e14a 100644 --- a/scripts/e2e-rights-matrix-support.ts +++ b/scripts/e2e-rights-matrix-support.ts @@ -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 => { - 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 => - page.url().startsWith(`${ctx.podFront}/authorize`) - && (await accept.first().isVisible().catch(() => false)) - && (await accept.first().isEnabled().catch(() => false)); - const shellReady = async (): Promise => - 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 => { + 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 => + page.url().startsWith(`${ctx.podFront}/authorize`) + && (await accept.first().isVisible().catch(() => false)) + && (await accept.first().isEnabled().catch(() => false)); + const shellReady = async (): Promise => + 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)'}` }; } /** diff --git a/scripts/e2e-rights-matrix.ts b/scripts/e2e-rights-matrix.ts index f8f7052..1c14e6b 100644 --- a/scripts/e2e-rights-matrix.ts +++ b/scripts/e2e-rights-matrix.ts @@ -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; @@ -747,6 +768,160 @@ async function tracked(): Promise { }); } +/** + * 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 { + const G = 'backend public surface (anonymous GET, status codes only)'; + const status = async (path: string): Promise => (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,}$/, '/'); + 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/, /interop/access-need-group/, /interop/access-need/, /.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/ 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 { + 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 = {}): 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 { + 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 => + (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 => { + 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 { const G = 'UI drives'; // Expected refusals are exact URLs: this run's profile documents, and each @@ -787,6 +962,7 @@ async function uiHygiene(): Promise { // 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 { 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 => { diff --git a/scripts/validate.ts b/scripts/validate.ts index 6c5ce2a..553d623 100644 --- a/scripts/validate.ts +++ b/scripts/validate.ts @@ -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 { + 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; + 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 { 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();