Files
festipod/scripts/e2e-revocation.ts
T
Sylvain Duchesne 908f7ea973 Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work
Participation privacy (decision_2026-10-29_participation-privacy-mechanism):
a sign-up is a private Join naming the meeting point, addressed to nobody,
handled by the backend on emission. Attendee lists hold member URNs hashed
from (WebID, event, a secret kept in the participant's profile), so counts
stay public and only contacts recognise anyone; the creator has no read
right and is notified anonymously. Each person's participations collection,
created by the backend at the first sign-up, is readable by their /contacts
WAC group only, never world-readable. The member URN is sticky per
(meeting point, participant) and every collection write is verified by
reading back, so a refused write leaves the act unrecorded and retryable.
The EventRef mechanism and its browser-side provisioning are gone.

The backend now runs with WebACL on, as the reference apps do, on its own
secure datasets festipod / settings-festipod instead of the pod provider's
settings dataset; pod-notifications needs it. The compose header and the
deployment leaf say how to reset a stack whose datasets predate this.

The rights matrix covers the new mechanism live (148 hard lines), including
a forged contact Accept between throwaway accounts: the stranger enters the
contacts list (platform defect, reported upstream) but is refused the profile
and the participations. Tests may attack as long as they never break the dev
environment (rule_tests-never-break-the-dev-environment).

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
2026-10-30 17:06:38 +01:00

403 lines
20 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
* `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<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);
});