9381c206df
e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
161 lines
7.8 KiB
TypeScript
161 lines
7.8 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(() => {});
|
|
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));
|
|
|
|
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,
|
|
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);
|
|
});
|