Files
festipod/scripts/e2e-login-flow.ts
T

187 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 `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.
*
* 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 { podLogin, readResource, type ApodsStackConfig } from '../src/shared/support/activitypodsStack';
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 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[] = [];
const hops: string[] = [];
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);
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)));
}
/** Drive picker → pod login → consent until the app shell is reached. */
async function driveLogin(page: Page): Promise<boolean> {
let authorizeSeen = 0;
for (let i = 0; i < 20 && Date.now() < DEADLINE; i++) {
const url = page.url();
if (hops[hops.length - 1] !== clean(url)) hops.push(clean(url));
if (url.includes(':5000') && (await page.locator('input[name="username"]').count().catch(() => 0))) {
hops.push(' [login] submitting credentials');
await page.locator('input[name="username"]').fill(USER).catch(() => {});
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
if (await btn.count()) await btn.first().click().catch(() => {});
else await page.locator('button[type=submit]').first().click().catch(() => {});
await page.waitForTimeout(3500);
continue;
}
if (url.includes(':5000/authorize')) {
authorizeSeen++;
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
let visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
hops.push(` [authorize #${authorizeSeen}] accept visible=${visible}`);
await snap(page, 'login-flow-consent');
if (visible) { await acc.first().click().catch(() => {}); hops.push(' [authorize] clicked accept'); }
if (authorizeSeen >= 3) { hops.push(' [LOOP] authorize revisited 3x — consent loop'); return false; }
await page.waitForTimeout(5000);
continue;
}
if (url.startsWith(APP)) {
if (url.includes('/login')) { await page.waitForTimeout(5000); continue; }
const picker = await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0);
if (!picker) { hops.push(' [app] shell reached'); return true; }
}
await page.waitForTimeout(1500);
}
return false;
}
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(() => {});
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) via the shared helper — the UI login then uses the same creds.
await podLogin(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);
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
const authenticated = await driveLogin(page);
await snap(page, 'login-flow-after-login');
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));
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,
eventCreated,
meetingPointCreated,
redirects,
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);
});