Files
festipod/scripts/e2e-login-flow.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

171 lines
8.4 KiB
TypeScript

/**
* Banked login-flow probe: drives the REAL local ActivityPods stack through the
* browser and asserts the whole app-install path — picker → pod login → consent →
* authenticated app → create event → create meeting point.
*
* WHY A SCRIPT, NOT A CUCUMBER STEP. The `@browser` smoke only checks the picker
* renders; the consent hop (an OIDC interaction on the pod frontend) and the
* app-registration round-trip are what actually proved the stack aligned. This
* runs it end-to-end and fails on any UNEXPECTED ≥400, so the regression is
* caught without a human driving the browser.
*
* ORIGIN. The OIDC redirect_uri is registered for `http://localhost:4000/login`
* 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 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');
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: string = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
const USER = `flow${Date.now()}`;
const PASS = 'Test1test';
const DEADLINE = Date.now() + 8 * 60 * 1000;
interface HttpError { url: string; status: number; method: string; body: string }
const redirects: string[] = [];
const errors4xx: HttpError[] = [];
const consoleErrors: 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 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);
function wire(page: Page): void {
let last = '';
page.on('framenavigated', f => {
if (f === page.mainFrame() && f.url() !== last) {
redirects.push(`${last || '(start)'} -> ${f.url()}`);
last = f.url();
}
});
page.on('response', r => {
if (r.status() >= 400) {
const method = r.request()?.method?.() ?? '?';
r.text()
.then(b => errors4xx.push({ url: r.url(), status: r.status(), method, body: b.slice(0, 400) }))
.catch(() => errors4xx.push({ url: r.url(), status: r.status(), method, body: '(unreadable)' }));
}
});
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 300)); });
page.on('pageerror', e => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 300)));
}
async function createEvent(page: Page): Promise<boolean> {
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('Concert flow').catch(() => {});
const dates = page.locator('input[type="date"]');
if (await dates.count()) await dates.first().fill('2026-12-20').catch(() => {});
await page.getByRole('button', { name: /Suivant/ }).click().catch(() => {});
await page.waitForTimeout(1200);
const next = page.getByRole('button', { name: /Suivant/ });
if (await next.count()) await next.click().catch(() => {});
await page.waitForTimeout(1200);
await page.getByRole('button', { name: /Relayer l'événement/ }).click().catch(() => {});
await page.waitForTimeout(5000);
const u = page.url();
return /\/events\/.+/.test(u) && !u.includes('/events/new');
}
async function createMeetingPoint(page: Page): Promise<boolean> {
const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i });
if (!(await propose.count())) return false;
await propose.first().click().catch(() => {});
await page.waitForTimeout(1500);
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill('Rdv flow').catch(() => {});
// The place is the form's mandatory field (title optional) — without it the
// submit bails on the client and nothing is created.
await page.locator('input[placeholder="Café en face du tiers-lieu"]').fill('Devant la gare').catch(() => {});
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click().catch(() => {});
await page.waitForTimeout(5000);
const txt = await bodyText(page);
return /POINTS DE RENCONTRE|Rdv flow/i.test(txt) && !/pas pu/i.test(txt);
}
/** The two benign ≥400 classes; anything else is a real failure. */
function classify(e: HttpError): string | null {
if (e.url.includes('/.notifications/WebSocketChannel2023')) return 'websocket-channel 403 (benign)';
if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return 'WAC profile-read 403 (benign)';
return null;
}
(async () => {
// 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);
// 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;
await snap(page, 'login-flow-after-meeting-point');
await ctx.close();
await browser.close();
await new Promise(r => setTimeout(r, 1200));
// 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 ${session.token}` }, signal: AbortSignal.timeout(60000),
}).then(r => r.status).catch(() => 0);
const fatal = errors4xx.filter(e => classify(e) === null);
const benign = errors4xx.map(e => ({ e, label: classify(e) })).filter(x => x.label).map(x => ({ status: x.e.status, url: x.e.url, label: x.label }));
const report = {
user: USER,
authenticated,
accountDeleted,
login: login.detail,
installed: install.actual,
eventCreated,
meetingPointCreated,
redirects,
hops: drive.hops,
benign4xx: benign,
fatal4xx: fatal,
consoleErrors,
ok: authenticated && eventCreated && meetingPointCreated && 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), redirects, errors4xx, consoleErrors }, null, 2));
process.exit(1);
});