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

171 lines
8.5 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 the app origin `/login` (default http://localhost:4005)
* 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 the app origin `/` — 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 || 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: 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);
});