/** * Revocation probe: drives the REAL local ActivityPods stack through the browser * and asserts that Festipod notices when its registration on the user's pod is * revoked, instead of staying in a "connected" view whose writes cannot land. * * THREE PHASES, one fresh user: * * 1. CURRENT — picker → pod login → consent → app. The app-status probe runs on * load (the GET of the pod's `/.well-known/app-status` is observed on the * wire, with its body, and the app logs `[AppStatus] probe (load) → current`); * a focus + visibilitychange right after does NOT probe at once (one-minute * throttle); an event is created and read back from the pod; a * visibilitychange inside the minute is deferred, not dropped — its probe * lands at the end of the minute and reads current. * 2. REVOKED — the registration is removed on the pod * (`POST {pod}/.auth-agent/app-registrations/remove`, the user's own token). * Without any reload, a window focus inside the minute — the user coming * back from the pod's tab — must, by the end of that minute, read the * revocation (`installed:false`) and LOG THE USER OUT: the picker with the * notice that the app's authorization was removed, NO redirect to the * consent screen (a deliberate revocation is not sent back to consent), no * session left (Back and a fresh load stay on the picker; the event-creation * drive that worked in phase 1 lands on the picker, never on success, and * 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. 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, * visibilitychange); its one-minute throttle is untouched. * * ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for * `http://localhost:4000/login` only, so the app MUST be served at APP_URL. * * Exit code 0 only when every phase holds; the JSON report says which did not. */ import { chromium, type Page } from 'playwright'; import { linkId, podLogin, readResource, recordUri, 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'); const APP: string = strip(process.env.APP_URL || 'http://localhost:4000'); const config: ApodsStackConfig = { podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'), backendUrl: BACKEND, }; const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000'); const USER = `revoke${Date.now()}`; const PASS = 'Test1test'; const DEADLINE = Date.now() + 10 * 60 * 1000; type Phase = 'current' | 'revoked' | 'recovered'; let phase: Phase = 'current'; interface StatusSeen { phase: Phase; status: number; body: unknown } interface HttpError { phase: Phase; url: string; status: number; method: string; body: string } const statusSeen: StatusSeen[] = []; const probeLogs: Array<{ phase: Phase; at: number; text: string }> = []; const errors4xx: HttpError[] = []; const writes: Array<{ phase: Phase; method: string; url: string; status: number }> = []; const consoleErrors: string[] = []; const redirects: 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 => page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim()); const snap = (page: Page, name: string): Promise => page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined); const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); function wire(page: Page): void { let last = ''; page.on('framenavigated', f => { if (f === page.mainFrame() && f.url() !== last) { redirects.push(`[${phase}] ${clean(last || '(start)')} -> ${clean(f.url())}`); last = f.url(); } }); page.on('response', r => { const method = r.request()?.method?.() ?? '?'; const url = r.url(); if (method === 'GET' && url.endsWith('/.well-known/app-status')) { const at = phase; r.json() .then(body => statusSeen.push({ phase: at, status: r.status(), body })) .catch(() => statusSeen.push({ phase: at, status: r.status(), body: '(unreadable)' })); } if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && url.startsWith(config.podProviderUrl)) { writes.push({ phase, method, url: clean(url), status: r.status() }); } if (r.status() >= 400) { const at = phase; r.text() .then(b => errors4xx.push({ phase: at, url, status: r.status(), method, body: b.slice(0, 300) })) .catch(() => errors4xx.push({ phase: at, url, status: r.status(), method, body: '(unreadable)' })); } }); page.on('console', m => { const text = m.text(); if (text.includes('[AppStatus]')) probeLogs.push({ phase, at: Date.now(), text: text.slice(0, 400) }); if (m.type() === 'error') consoleErrors.push(`[${phase}] ${text.slice(0, 300)}`); }); page.on('pageerror', e => consoleErrors.push(`[${phase}] pageerror: ${String(e.message).slice(0, 300)}`)); } const onApp = (page: Page): boolean => page.url().startsWith(APP); const pickerShown = async (page: Page): Promise => (await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0)) > 0; const whereIs = async (page: Page): Promise => !onApp(page) ? (page.url().startsWith(`${POD_FRONT}/authorize`) ? 'consent' : `elsewhere:${clean(page.url())}`) : (await pickerShown(page)) ? 'picker' : 'still-connected'; /** 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; const toastWatch = setInterval(() => { void page.locator('text=Événement relayé').count().then(n => { if (n) successToast = true; }).catch(() => {}); }, 250); try { 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: 3000 }).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: 3000 }).catch(() => {}); await page.waitForTimeout(1200); const next = page.getByRole('button', { name: /Suivant/ }); if (await next.count().catch(() => 0)) await next.click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(1200); await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(5000); } finally { clearInterval(toastWatch); } const finalUrl = page.url(); const m = finalUrl.startsWith(`${APP}/events/`) ? /\/events\/([^/?#]+)/.exec(finalUrl.slice(APP.length)) : null; const segment = m?.[1]; const eventUri = segment && segment !== 'new' ? decodeURIComponent(segment) : undefined; return { eventUri, successToast, finalUrl }; } /** Does the user's pod hold an event with this title? Read with the user's own token. */ async function podHoldsEventTitled(session: ApodsSession, title: string): Promise { const container = await readResource(session, `${session.storage}/as/event`).catch(() => undefined); if (!container) return false; if (JSON.stringify(container).includes(title)) return true; const raw = container['ldp:contains'] ?? container['contains']; const members = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(linkId).filter((u): u is string => !!u); for (const uri of members) { const rec = await readResource(session, uri).catch(() => undefined); if (rec && JSON.stringify(rec).includes(title)) return true; } return false; } /** Wait until a predicate holds or the bound elapses. */ async function waitFor(pred: () => boolean | Promise, ms: number): Promise { const end = Date.now() + ms; while (Date.now() < end) { if (await pred()) return true; await sleep(250); } return pred(); } /** Fire the real DOM event a probe listener is attached to. */ const fire = (page: Page, trigger: 'focus' | 'visibility'): Promise => page.evaluate((which) => { if (which === 'focus') window.dispatchEvent(new Event('focus')); else document.dispatchEvent(new Event('visibilitychange')); }, trigger).catch(() => {}); const lastProbeAt = (): number => Math.max(0, ...probeLogs.filter(l => l.text.includes('probe (')).map(l => l.at)); /** * The probe's own schedule, exercised live and never shortened: fire `trigger` * less than a minute after the app's last probe, check that NO probe starts at * once (throttle), then wait for the deferred probe of that trigger to land * with the expected verdict at the end of the minute (not dropped). */ async function deferredProbe(page: Page, trigger: 'focus' | 'visibility', verdict: RegExp): Promise<{ firedAfterLastProbeMs: number; immediate: boolean; probed: boolean; gapFromLastProbeMs?: number; }> { const last = lastProbeAt(); const firedAfterLastProbeMs = Date.now() - last; const before = probeLogs.length; await fire(page, trigger); await page.waitForTimeout(3000); const immediate = probeLogs.slice(before).some(l => l.text.includes('probe (')); const pattern = new RegExp(`probe \\(${trigger}\\) → ${verdict.source}`); const probed = await waitFor(() => probeLogs.slice(before).some(l => pattern.test(l.text)), 75_000); const hit = probeLogs.slice(before).find(l => pattern.test(l.text)); return { firedAfterLastProbeMs, immediate, probed, gapFromLastProbeMs: hit ? hit.at - last : undefined }; } const isCurrent = (b: unknown): boolean => typeof b === 'object' && b !== null && (b as Record)['installed'] === true && (b as Record)['upgradeNeeded'] === false; /** Benign ≥400 by design (same classes as e2e-login-flow.ts). */ function benign(e: HttpError): boolean { if (e.url.includes('/.notifications/WebSocketChannel2023')) return true; if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return true; return false; } (async () => { const user = await podLogin(config, USER, PASS); const appDescription = await readResource(undefined, `${BACKEND}/app`); const appUri = recordUri(appDescription); if (!appUri) throw new Error('GET /app carries no id (the app actor URI)'); 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); // ── Phase 1: CURRENT ──────────────────────────────────────────────────── // 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)), 15000, ); // Throttle, observed through the real listeners: focus + visibilitychange // seconds after the load probe must not issue another status request. const beforeNudge = statusSeen.filter(s => s.phase === 'current').length; await page.evaluate(() => { window.dispatchEvent(new Event('focus')); document.dispatchEvent(new Event('visibilitychange')); }).catch(() => {}); await page.waitForTimeout(3000); const afterNudge = statusSeen.filter(s => s.phase === 'current').length; const throttled = beforeNudge >= 1 && afterNudge === beforeNudge; const titleA = `Concert courant ${USER}`; const writeA = connected ? await createEvent(page, titleA) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string }; const writeAOnPod = writeA.eventUri ? await podHoldsEventTitled(user, titleA) : false; await snap(page, 'revocation-current-after-write'); // A visibilitychange right after the write's reload: deferred, then current. const visibilityProbe = connected ? await deferredProbe(page, 'visibility', /current/) : undefined; // ── Phase 2: REVOKED ──────────────────────────────────────────────────── phase = 'revoked'; const revoke = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/remove`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${user.token}` }, body: JSON.stringify({ appUri }), }); const revokeResult = { status: revoke.status, body: (await revoke.text()).slice(0, 300) }; // Removal is processed by the pod and the app backend; give it a moment to settle. await sleep(3000); // (a) FOCUS, no reload — the user revokes in the pod's tab and comes back // within the minute of the last probe: the deferred focus probe must read the // revocation and take the app off the connected view. const focusProbe = await deferredProbe(page, 'focus', /stale \(not-installed\)/); const leftByFocus = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000); const leftByFocusTo = await whereIs(page); await snap(page, 'revocation-revoked-left-by-focus'); const notice = page.locator('[data-testid="auth-picker-notice"]'); const noticeShown = onApp(page) && (await notice.count().catch(() => 0)) > 0; const noticeText = noticeShown ? ((await notice.first().innerText().catch(() => '')) as string).replace(/\s+/g, ' ').trim() : ''; // (b) No session left: Back must not return to the connected view, a fresh // load must stay on the picker, and the same event-creation drive that worked // in phase 1 must not reach success (it lands on the picker). await page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => {}); await page.waitForTimeout(2000); const stillPickerAfterBack = (await whereIs(page)) === 'picker'; await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }).catch(() => {}); await page.waitForTimeout(2500); const stillPickerAfterReload = (await whereIs(page)) === 'picker'; const titleB = `Concert revoque ${USER}`; const writeB = await createEvent(page, titleB); await snap(page, 'revocation-revoked-after-write-attempt'); const leftAfterReload = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000); const leftAfterReloadTo = await whereIs(page); const writeBOnPod = await podHoldsEventTitled(user, titleB); const consentRedirectInRevoked = redirects.some(r => r.startsWith('[revoked]') && r.includes(`${clean(POD_FRONT)}/authorize`)); const pageWritesInRevoked = writes.filter(w => w.phase === 'revoked'); await snap(page, 'revocation-revoked-left'); // ── Phase 3: RECOVERED ────────────────────────────────────────────────── phase = 'recovered'; // Sign in again from the picker: login → registerApp → consent → app. const signInAgain = page.getByRole('button', { name: /Se connecter avec localhost:3000/i }); let reconnected = false; if (leftAfterReloadTo === 'picker' && (await signInAgain.count().catch(() => 0))) { // 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 = 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, ); const titleC = `Concert retabli ${USER}`; const writeC = reconnected ? await createEvent(page, titleC) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string }; const writeCOnPod = writeC.eventUri ? await podHoldsEventTitled(user, titleC) : false; await snap(page, 'revocation-recovered-after-write'); await ctx.close(); await browser.close(); await sleep(1000); // User decision 2026-10-26: the suite must not accumulate pod datasets. The // platform tombstones the account and drops its dataset on a delay. const accountDeleted = await fetch(`${config.podProviderUrl}/.account/${USER}`, { method: 'DELETE', headers: { Authorization: `Bearer ${user.token}` }, signal: AbortSignal.timeout(60000), }).then(r => r.status).catch(() => 0); // The deferred probe lands at the end of the minute: never before (throttle), // and not much after (it was deferred, not dropped). Measured between the // app's own log lines, so a few hundred ms of request latency either way. const deferredOnTime = (p?: { immediate: boolean; probed: boolean; gapFromLastProbeMs?: number }): boolean => !!p && !p.immediate && p.probed && p.gapFromLastProbeMs !== undefined && p.gapFromLastProbeMs >= 58_000 && p.gapFromLastProbeMs <= 75_000; const positive = { connected, probeRanCurrent, throttled, statusRequestsAfterNudge: afterNudge, write: { ...writeA, onPod: writeAOnPod }, visibilityProbe, ok: connected && probeRanCurrent && throttled && !!writeA.eventUri && writeA.successToast && writeAOnPod && deferredOnTime(visibilityProbe), }; const negative = { revoke: revokeResult, focusProbe, leftByFocus, leftByFocusTo, noticeShown, noticeText, consentRedirectInRevoked, pageWritesInRevoked, stillPickerAfterBack, stillPickerAfterReload, leftAfterReload, leftAfterReloadTo, writeAttempt: { ...writeB, onPod: writeBOnPod }, ok: (revoke.status === 200 || revoke.status === 204) && deferredOnTime(focusProbe) && leftByFocus && leftByFocusTo === 'picker' && noticeShown && noticeText.includes('retirée') && !consentRedirectInRevoked && !pageWritesInRevoked.length && stillPickerAfterBack && stillPickerAfterReload && leftAfterReloadTo === 'picker' && !writeB.eventUri && !writeB.successToast && !writeBOnPod, }; const recovered = { reconnected, consentReachedOnSignIn, probeReadsCurrentAgain, write: { ...writeC, onPod: writeCOnPod }, ok: reconnected && consentReachedOnSignIn && probeReadsCurrentAgain && !!writeC.eventUri && writeC.successToast && writeCOnPod, }; // Phase 2's ≥400s are the revocation's own consequences; the other phases must be clean. const fatal = errors4xx.filter(e => e.phase !== 'revoked' && !benign(e)); const report = { user: USER, appUri, accountDeleted, positive, negative, recovered, statusBodies: statusSeen, probeLogs, writes, revokedPhase4xx: errors4xx.filter(e => e.phase === 'revoked').map(e => `${e.status} ${e.method} ${clean(e.url)}`), fatal4xx: fatal, redirects, hops: drive.hops, consoleErrors, ok: positive.ok && negative.ok && recovered.ok && fatal.length === 0, }; console.log(JSON.stringify(report, null, 2)); process.exit(report.ok ? 0 : 1); })().catch(err => { console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), phase, statusSeen, probeLogs, redirects, errors4xx, consoleErrors }, null, 2)); process.exit(1); });