/** * Support for `e2e-rights-matrix.ts`: the per-line report, raw HTTP with a * user's token (or none), account bootstrap with programmatic app install, and * the browser drives (pod login + consent, event and meeting-point creation). * * 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. * * 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 * as the actual value. A refusal is matched on an explicit status only * (`refused`), so a 404, a 500 or a network error (status 0) never counts as one. */ import type { Page } from 'playwright'; import { linkId, podLogin, readResource, recordUri, type ApodsSession, type ApodsStackConfig, type JsonRecord, } from '../src/shared/support/activitypodsStack'; // ── Report ──────────────────────────────────────────────────────────────── /** How a line weighs on the exit code: only `hard` lines can fail the run. */ export type LineKind = 'hard' | 'known-deviation' | 'known-defect' | 'manual' | 'info'; export type LineStatus = 'PASS' | 'FAIL' | 'KNOWN-DEVIATION' | 'KNOWN-DEFECT' | 'MANUAL-CHECK' | 'INFO'; export interface CheckOutcome { ok: boolean; actual: string; /** Raw evidence (status codes, bodies, ids) printed under the line. */ evidence?: string; } export interface LineResult { id: string; group: string; name: string; kind: LineKind; expected: string; actual: string; status: LineStatus; evidence?: string; /** A tracked classification printed with a FAIL (it never turns it into a pass). */ finding?: string; /** A loud notice: a known line whose observed behaviour changed. */ notice?: string; } const errorText = (e: unknown): string => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)).slice(0, 300); export class Matrix { readonly lines: LineResult[] = []; /** A hard assertion: PASS only when the check returns ok; anything else is FAIL. */ async hard( id: string, group: string, name: string, expected: string, check: () => Promise, opts: { finding?: string } = {}, ): Promise { let outcome: CheckOutcome; try { outcome = await check(); } catch (e) { outcome = { ok: false, actual: `ERROR ${errorText(e)}` }; } const status: LineStatus = outcome.ok ? 'PASS' : 'FAIL'; this.push({ id, group, name, kind: 'hard', expected, actual: outcome.actual, status, evidence: outcome.evidence, finding: status === 'FAIL' ? opts.finding : undefined, }); return outcome.ok; } /** * A tracked, non-fatal line (known deviation or known defect). `classify` * receives the observation and says whether the tracked behaviour still holds; * when it does not, the line carries a loud notice (promote it). */ async known( kind: 'known-deviation' | 'known-defect', id: string, group: string, name: string, expected: string, observe: () => Promise, ): Promise { let outcome: CheckOutcome & { notice?: string }; try { outcome = await observe(); } catch (e) { outcome = { ok: false, actual: `ERROR ${errorText(e)}`, notice: 'the observation itself failed — the tracked behaviour is unverified this run' }; } this.push({ id, group, name, kind, expected, actual: outcome.actual, status: kind === 'known-defect' ? 'KNOWN-DEFECT' : 'KNOWN-DEVIATION', evidence: outcome.evidence, notice: outcome.notice, }); } /** * A MANUAL-CHECK line: an assertion this script deliberately does NOT exercise * live (it would send a forged or spoofed activity — user decision 2026-10-13, * defensive only). Printed with where it is covered; never affects the exit code. */ manual(id: string, group: string, name: string, coverage: string): void { this.push({ id, group, name, kind: 'manual', expected: '(not exercised live)', actual: coverage, status: 'MANUAL-CHECK' }); } /** An informational line: never affects the exit code; `notice` flags an observation that broke its stated premise. */ info(id: string, group: string, name: string, actual: string, notice?: string): void { this.push({ id, group, name, kind: 'info', expected: '(informational)', actual, status: 'INFO', notice }); } private push(line: LineResult): void { this.lines.push(line); const mark = line.status === 'FAIL' ? 'FAIL' : line.status; console.error(`[matrix] ${mark.padEnd(15)} ${line.id} — ${line.name} :: ${line.actual}`); } get hardFailures(): LineResult[] { return this.lines.filter(l => l.kind === 'hard' && l.status === 'FAIL'); } print(header: string): void { const out: string[] = []; out.push('', '═'.repeat(100), `RIGHTS MATRIX — ${header}`, '═'.repeat(100)); let group = ''; for (const l of this.lines) { if (l.group !== group) { group = l.group; out.push('', `── ${group} ${'─'.repeat(Math.max(0, 95 - group.length))}`); } out.push(`${l.status.padEnd(15)} ${l.id} ${l.name}`); out.push(`${' '.repeat(16)}expected: ${l.expected}`); out.push(`${' '.repeat(16)}actual: ${l.actual}`); if (l.evidence) out.push(`${' '.repeat(16)}evidence: ${l.evidence}`); if (l.finding) out.push(`${' '.repeat(16)}finding: ${l.finding}`); if (l.notice) out.push(`${' '.repeat(16)}!!! NOTICE: ${l.notice}`); } const count = (s: LineStatus): number => this.lines.filter(l => l.status === s).length; const notices = this.lines.filter(l => l.notice); out.push('', '═'.repeat(100)); out.push(`hard lines: ${this.lines.filter(l => l.kind === 'hard').length} — PASS ${count('PASS')}, FAIL ${count('FAIL')}; ` + `KNOWN-DEVIATION ${count('KNOWN-DEVIATION')}, KNOWN-DEFECT ${count('KNOWN-DEFECT')}, MANUAL-CHECK ${count('MANUAL-CHECK')}, INFO ${count('INFO')}`); for (const l of this.hardFailures) out.push(`FAILED: ${l.id} — ${l.name}${l.finding ? ` [${l.finding.split(':')[0]}]` : ''}`); for (const l of notices) { out.push('', '!'.repeat(100), `!!! ${l.id} (${l.status}): ${l.notice}`, '!'.repeat(100)); } out.push(`VERDICT: ${this.hardFailures.length === 0 ? 'PASS — every hard line holds' : `FAIL — ${this.hardFailures.length} hard line(s) failed`}`); out.push('═'.repeat(100)); console.log(out.join('\n')); } } /** Throw a named prerequisite failure (the line then FAILs, it is never skipped). */ export function need(value: T | undefined | null | false, what: string): T { if (value === undefined || value === null || value === false) throw new Error(`prerequisite missing: ${what}`); return value; } /** Is `status` one of the explicit refusals `allowed`? 0 (unreachable), 404, 5xx never are. */ export const refused = (status: number, allowed: number[]): boolean => allowed.includes(status); // ── HTTP ────────────────────────────────────────────────────────────────── export interface HttpResult { status: number; text: string; headers: Headers; error?: string } /** * One request, bounded. A network failure or timeout comes back as status 0 * with the error text — never thrown, never a status a refusal could match. */ export async function http( method: string, url: string, opts: { token?: string; body?: string; contentType?: string; accept?: string; timeoutMs?: number } = {}, ): Promise { const headers = new Headers({ Accept: opts.accept ?? 'application/ld+json' }); if (opts.token) headers.set('Authorization', `Bearer ${opts.token}`); if (opts.contentType) headers.set('Content-Type', opts.contentType); try { const r = await fetch(url, { method, headers, body: opts.body, signal: AbortSignal.timeout(opts.timeoutMs ?? 15000) }); return { status: r.status, text: await r.text(), headers: r.headers }; } catch (e) { return { status: 0, text: '', headers: new Headers(), error: errorText(e) }; } } export const brief = (r: HttpResult): string => r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}${r.text ? ` ${r.text.replace(/\s+/g, ' ').slice(0, 160)}` : ''}`; export function parseJson(text: string): JsonRecord | undefined { try { const v: unknown = JSON.parse(text); return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as JsonRecord) : undefined; } catch { return undefined; } } export const arrayOf = (value: unknown): unknown[] => value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]; /** The member URIs of an attendees collection body (`items`, no `totalItems` on this platform). */ export function itemUris(record: JsonRecord | undefined): string[] { if (!record) return []; return arrayOf(record['items'] ?? record['as:items']).map(linkId).filter((u): u is string => !!u); } export const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); /** Poll `probe` until `done` holds or the bound elapses; returns the last value either way. */ export async function poll(probe: () => Promise, done: (v: T) => boolean, ms: number, every = 1000): Promise { const end = Date.now() + ms; let v = await probe(); while (!done(v) && Date.now() < end) { await sleep(every); v = await probe(); } return v; } // ── Accounts ────────────────────────────────────────────────────────────── /** * `podLogin` with retries: a fresh pod signup can outlive the helper's 15 s * timeout while still succeeding server-side, so a retry logs in. */ export async function loginWithRetry(config: ApodsStackConfig, username: string, password: string): Promise { let last: unknown; for (let attempt = 0; attempt < 4; attempt++) { try { return await podLogin(config, username, password); } catch (e) { last = e; await sleep(5000); } } throw new Error(`podLogin(${username}) failed after retries: ${errorText(last)}`); } /** * Programmatic app install (the smoke's install step): register the app ACTOR * URI with every access need of its group, then wait for the registered Event * container to exist. */ export async function installApp(config: ApodsStackConfig, session: ApodsSession): Promise<{ ok: boolean; actual: string }> { const description = await readResource(undefined, `${config.backendUrl}/app`); const appUri = need(recordUri(description), 'app actor URI (id of GET /app)'); const groupUri = need(linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI'); const group = await readResource(undefined, groupUri); const body = JSON.stringify({ appUri, acceptedAccessNeeds: arrayOf(group['interop:hasAccessNeed']), acceptedSpecialRights: arrayOf(group['apods:hasSpecialRights']), }); const reg = await http('POST', `${config.podProviderUrl}/.auth-agent/app-registrations/register`, { token: session.token, body, contentType: 'application/json', accept: 'application/json', timeoutMs: 30000, }); if (reg.status < 200 || reg.status >= 300) return { ok: false, actual: `register → ${brief(reg)}` }; const container = await poll( () => http('GET', `${session.storage}/as/event`, { token: session.token }), r => r.status === 200, 30000, 500, ); return { ok: container.status === 200, actual: `register → HTTP ${reg.status}; ${session.storage}/as/event → ${brief(container).slice(0, 40)}` }; } /** The profile document an actor links (`url`), read from the actor document. */ export async function profileUriOf(webId: string): Promise { const r = await http('GET', webId); return linkId(parseJson(r.text)?.['url']); } // ── Browser drives ──────────────────────────────────────────────────────── export interface UiHttpError { who: string; url: string; status: number; method: string; body: string } export interface UiPageError { who: string; url: string; message: string; stack: string } export interface DriveContext { app: string; podFront: string; hops: string[]; errors: UiHttpError[]; consoleErrors: string[]; /** Uncaught exceptions thrown in a page, with the page URL at the time. */ pageErrors: UiPageError[]; deadline: number; } const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.'); export const bodyText = (page: Page): Promise => page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim()); /** Record every ≥400 response, console error and uncaught page error of a page, tagged by user. */ export function wire(page: Page, who: string, ctx: DriveContext): void { page.on('response', r => { if (r.status() >= 400) { const method = r.request()?.method?.() ?? '?'; r.text() .then(b => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: b.slice(0, 300) })) .catch(() => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: '(unreadable)' })); } }); page.on('console', m => { if (m.type() === 'error') ctx.consoleErrors.push(`[${who}] ${m.text().slice(0, 300)}`); }); page.on('pageerror', e => ctx.pageErrors.push({ who, url: page.url(), message: String(e.message).slice(0, 300), stack: String(e.stack ?? '').slice(0, 600) })); } /** What a UI drive is EXPECTED to be refused, per user — every entry an exact URL. */ export interface ExpectedRefusals { /** This run's profile documents: a profile answers only whom it grants (the Alice/Bob rule). */ profiles: ReadonlySet; /** Each user's OWN `pim:preferencesFile`: read by the app with its app token, which the pod does not grant (VERIFIED 2026-10-13). */ ownPreferences: ReadonlyMap; } /** * The expected ≥400 responses of a UI drive, each an exact request: the pod's * push channel creation (POST …/.notifications/WebSocketChannel2023 → 403), * a GET 403 on one of this run's profile documents, and a GET 403 on the * signed-in user's OWN preferences file. Anything else — another 403 under * `/data/` included — is unexpected. */ export function benignUiError(e: UiHttpError, expected: ExpectedRefusals): string | null { if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) return 'push-channel creation 403'; if (e.method === 'GET' && e.status === 403 && expected.profiles.has(e.url)) return 'profile-document read 403 (WAC)'; if (e.method === 'GET' && e.status === 403 && expected.ownPreferences.get(e.who) === e.url) return 'own preferences-file read 403 (app token not granted)'; return null; } /** * The uncaught page errors explained by an expected refusal: the live * provider's `subscribe` rejects with "Forbidden" when the pod refuses its * push channel — exactly one per refused channel POST of the same user * (VERIFIED 2026-10-13: Bob 2/2, Alice 4/4, Carol 4/4). The throw site is * identified by its two top stack frames, learnt from errors whose async stack * still reaches `subscribe` (half of them are cut before that frame, from the * very same site). Every other page error, and any surplus, is unexpected. */ export function splitPageErrors(errors: UiPageError[], http: UiHttpError[]): { expected: UiPageError[]; unexpected: UiPageError[] } { const channelRefusals = new Map(); for (const e of http) { if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) { channelRefusals.set(e.who, (channelRefusals.get(e.who) ?? 0) + 1); } } const site = (e: UiPageError): string => e.stack.split('\n').slice(1, 3).map(l => l.trim().replace(/^at async /, 'at ')).join(' < '); const subscribeSites = new Set(errors.filter(e => e.message === 'Forbidden' && /\bsubscribe\b/.test(e.stack)).map(site)); const expected: UiPageError[] = []; const unexpected: UiPageError[] = []; for (const e of errors) { const budget = channelRefusals.get(e.who) ?? 0; if (e.message === 'Forbidden' && subscribeSites.has(site(e)) && budget > 0) { channelRefusals.set(e.who, budget - 1); expected.push(e); } else { unexpected.push(e); } } return { expected, unexpected }; } /** A user's own preferences file (`pim:preferencesFile` of the actor document, read with her token). */ export async function preferencesFileOf(session: ApodsSession): Promise { const r = await http('GET', session.webId, { token: session.token }); return linkId(parseJson(r.text)?.['pim:preferencesFile']); } export interface LoginOutcome { ok: boolean; detail: string } /** * App picker → pod login → consent → back in the app shell, as a sequence of * 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 * 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 * right after it opens. Accept leads back to the app, then to the shell. * * H1 (false "shell reached"): while the app is on its own origin between those * hops it renders neither the picker nor the shell. The shell therefore counts * only once the browser has been to the pod, the URL is past /login, and the * bottom navigation is visible with no picker. * H2 (accept never landing): Accept is clicked only once it has been attached, * visible and enabled for a full second (the page's second navigation is over), * and the click is then VERIFIED by the page leaving the consent screen; if it * does not, the drive fails with what the page showed — it never re-clicks. */ export async function driveLogin(page: Page, who: string, username: string, password: string, ctx: DriveContext): Promise { 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; 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); } 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)'}` }; } /** * Is the app installed on this user's pod? The registered Event container * exists only once the app registration went through (consent or programmatic * install). Polled: the registration is confirmed asynchronously. */ export async function installedContainer(session: ApodsSession, timeoutMs = 30000): Promise { const uri = `${session.storage}/as/event`; const r = await poll(() => http('GET', uri, { token: session.token }), v => v.status === 200, timeoutMs, 1000); return r.status === 200 ? { ok: true, actual: `${clean(uri)} → HTTP 200` } : { ok: false, actual: `${clean(uri)} → ${r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}`} after ${timeoutMs / 1000} s — the app is NOT installed on this pod (consent never completed / registration missing)` }; } /** The banked event-creation drive; the event URI is read off the detail route it lands on. */ export async function createEventUi(page: Page, app: string, title: string): Promise<{ eventUri?: string; finalUrl: string }> { await page.goto(`${app}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {}); await page.waitForTimeout(1500); await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 10000 }).catch(() => {}); const dates = page.locator('input[type="date"]'); if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {}); await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 5000 }).catch(() => {}); await page.waitForTimeout(1200); const next = page.getByRole('button', { name: /Suivant/ }); if (await next.count().catch(() => 0)) await next.click({ timeout: 5000 }).catch(() => {}); await page.waitForTimeout(1200); await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 5000 }).catch(() => {}); const landed = await poll(async () => page.url(), u => /\/events\/[^/]+$/.test(new URL(u).pathname) && !u.includes('/events/new'), 15000, 500); const m = landed.startsWith(`${app}/events/`) ? /\/events\/([^/?#]+)/.exec(landed.slice(app.length)) : null; const segment = m?.[1]; return { eventUri: segment && segment !== 'new' ? decodeURIComponent(segment) : undefined, finalUrl: landed }; } /** The banked meeting-point drive, from the event detail page it starts on. */ export async function createMeetingPointUi(page: Page, title: string): Promise { const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i }); await propose.first().waitFor({ timeout: 15000 }).catch(() => {}); if (!(await propose.count().catch(() => 0))) return false; await propose.first().click().catch(() => {}); await page.waitForTimeout(1500); await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill(title, { timeout: 10000 }).catch(() => {}); await page.getByRole('button', { name: /Créer le point de rencontre/ }).click({ timeout: 5000 }).catch(() => {}); const txt = await poll(() => bodyText(page), t => t.includes(title), 15000, 500); return txt.includes(title) && !/pas pu/i.test(txt); }