Files
festipod/scripts/e2e-revocation.ts
Sylvain Duchesne 5e3e821e2a Run locally like the sibling apps: fixed ports, the pod provider in Docker, the app outside
Festipod now follows the local convention of welcometomyplace, mutual-aid.app
and portejunes: one fixed port pair per app (Festipod defaults: backend 3005,
app 4005; pod provider 3000/5000), overridable in backend/.env and .env.
`make start` runs docker-compose-dev.yml (the local pod provider from images,
Fuseki, Redis, mailcatcher, Arena); the backend and the app run outside Docker
with `cd backend && pnpm dev` and `pnpm dev`. pnpm stays the package manager.

The old docker-compose.yml, which containerized Festipod's own frontend and
backend and could not reach the host's Fuseki and Redis, is gone; portless is
dropped from the dev script, since the backend declares a single OIDC redirect
URI. The local backend runs without a Redis cache, as the sibling apps do. The
e2e suites and validate read the app, backend and pod provider URLs from the
env files instead of hardcoding them.

The deployment and local cookbook leaves record the traps met while resetting
datasets: a dataset re-created under the same name needs a Fuseki restart
(ClosedException otherwise), the anonymous WebACL probe at boot is expected,
and a change of the backend's URL leaves persistent accounts with a
participations collection the new app identity cannot control.
2026-11-01 11:40:27 +01:00

403 lines
21 KiB
TypeScript

/**
* 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
* the app origin `/login` (default http://localhost:4005) 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 || process.env.VITE_BACKEND_URL || 'http://localhost:3005');
const APP: string = strip(process.env.APP_URL || `http://localhost:${process.env.PORT || 4005}`);
const config: ApodsStackConfig = {
podProviderUrl: strip(process.env.POD_PROVIDER_URL || process.env.VITE_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<string> =>
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
const snap = (page: Page, name: string): Promise<void> =>
page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined);
const sleep = (ms: number): Promise<void> => 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<boolean> =>
(await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0)) > 0;
const whereIs = async (page: Page): Promise<string> =>
!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<boolean> {
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<boolean>, ms: number): Promise<boolean> {
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<void> =>
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<string, unknown>)['installed'] === true && (b as Record<string, unknown>)['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);
});