From d38f8a738478ba96bf750a64dabb39938f924bd4 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 13 Oct 2026 14:49:39 +0200 Subject: [PATCH] Add the end-to-end rights matrix as a runnable gate: bun run test:e2e-rights Three fresh users plus an anonymous reader, on the live local stack: login, consent and app install are driven through the UI and asserted at setup; reads, refused writes, profile privacy, discovery, opening a foreign event, join and leave with their counts, isolation of each user's own pod, and catalog listing are each a named line with expected and actual, printed as a report. The exit code is non-zero iff a hard line fails. Known platform behaviours (attendee lists world-readable, an anonymous PATCH accepted on attendees collections) are tracked as non-fatal lines that flag a change; paths that need a crafted request are recorded as manual checks. --- package.json | 1 + scripts/e2e-rights-matrix-support.ts | 499 ++++++++++++++++ scripts/e2e-rights-matrix.ts | 814 +++++++++++++++++++++++++++ 3 files changed, 1314 insertions(+) create mode 100644 scripts/e2e-rights-matrix-support.ts create mode 100644 scripts/e2e-rights-matrix.ts diff --git a/package.json b/package.json index 12260e5..abeecf9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:auth-setup": "bun scripts/setup-test-auth.ts", "test:e2e-flow": "bun scripts/e2e-login-flow.ts", "test:e2e-revocation": "bun scripts/e2e-revocation.ts", + "test:e2e-rights": "bun scripts/e2e-rights-matrix.ts", "cucumber:report": "bun scripts/parse-test-results.ts", "features:parse": "bun scripts/parse-features.ts", "steps:extract": "bun scripts/extract-step-definitions.ts", diff --git a/scripts/e2e-rights-matrix-support.ts b/scripts/e2e-rights-matrix-support.ts new file mode 100644 index 0000000..a575366 --- /dev/null +++ b/scripts/e2e-rights-matrix-support.ts @@ -0,0 +1,499 @@ +/** + * Support for `e2e-rights-matrix.ts`: the per-line report, raw HTTP with a + * user's token (or none), account bootstrap with programmatic app install, and + * the browser drives (pod login + consent, event and meeting-point creation). + * + * WHY NOT IMPORT THE BANKED SCRIPTS. `e2e-login-flow.ts` and `e2e-revocation.ts` + * run their whole probe at import time (top-level IIFE) and their drives read + * module-level user constants, so their helpers cannot be imported. The drives + * below are the same sequences, parameterised by the user who drives. + * + * NEVER A SILENT PASS. A line's check is a function returning `{ ok, actual }`; + * a throw, a timeout or an unreachable host is recorded as FAIL with the error + * as the actual value. A refusal is matched on an explicit status only + * (`refused`), so a 404, a 500 or a network error (status 0) never counts as one. + */ + +import type { Page } from 'playwright'; +import { + linkId, + podLogin, + readResource, + recordUri, + type ApodsSession, + type ApodsStackConfig, + type JsonRecord, +} from '../src/shared/support/activitypodsStack'; + +// ── Report ──────────────────────────────────────────────────────────────── + +/** How a line weighs on the exit code: only `hard` lines can fail the run. */ +export type LineKind = 'hard' | 'known-deviation' | 'known-defect' | 'manual' | 'info'; +export type LineStatus = 'PASS' | 'FAIL' | 'KNOWN-DEVIATION' | 'KNOWN-DEFECT' | 'MANUAL-CHECK' | 'INFO'; + +export interface CheckOutcome { + ok: boolean; + actual: string; + /** Raw evidence (status codes, bodies, ids) printed under the line. */ + evidence?: string; +} + +export interface LineResult { + id: string; + group: string; + name: string; + kind: LineKind; + expected: string; + actual: string; + status: LineStatus; + evidence?: string; + /** A tracked classification printed with a FAIL (it never turns it into a pass). */ + finding?: string; + /** A loud notice: a known line whose observed behaviour changed. */ + notice?: string; +} + +const errorText = (e: unknown): string => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)).slice(0, 300); + +export class Matrix { + readonly lines: LineResult[] = []; + + /** A hard assertion: PASS only when the check returns ok; anything else is FAIL. */ + async hard( + id: string, group: string, name: string, expected: string, + check: () => Promise, + opts: { finding?: string } = {}, + ): Promise { + let outcome: CheckOutcome; + try { + outcome = await check(); + } catch (e) { + outcome = { ok: false, actual: `ERROR ${errorText(e)}` }; + } + const status: LineStatus = outcome.ok ? 'PASS' : 'FAIL'; + this.push({ + id, group, name, kind: 'hard', expected, actual: outcome.actual, status, evidence: outcome.evidence, + finding: status === 'FAIL' ? opts.finding : undefined, + }); + return outcome.ok; + } + + /** + * A tracked, non-fatal line (known deviation or known defect). `classify` + * receives the observation and says whether the tracked behaviour still holds; + * when it does not, the line carries a loud notice (promote it). + */ + async known( + kind: 'known-deviation' | 'known-defect', id: string, group: string, name: string, expected: string, + observe: () => Promise, + ): Promise { + let outcome: CheckOutcome & { notice?: string }; + try { + outcome = await observe(); + } catch (e) { + outcome = { ok: false, actual: `ERROR ${errorText(e)}`, notice: 'the observation itself failed — the tracked behaviour is unverified this run' }; + } + this.push({ + id, group, name, kind, expected, actual: outcome.actual, + status: kind === 'known-defect' ? 'KNOWN-DEFECT' : 'KNOWN-DEVIATION', + evidence: outcome.evidence, notice: outcome.notice, + }); + } + + /** + * A MANUAL-CHECK line: an assertion this script deliberately does NOT exercise + * live (it would send a forged or spoofed activity — user decision 2026-10-13, + * defensive only). Printed with where it is covered; never affects the exit code. + */ + manual(id: string, group: string, name: string, coverage: string): void { + this.push({ id, group, name, kind: 'manual', expected: '(not exercised live)', actual: coverage, status: 'MANUAL-CHECK' }); + } + + /** An informational line: never affects the exit code; `notice` flags an observation that broke its stated premise. */ + info(id: string, group: string, name: string, actual: string, notice?: string): void { + this.push({ id, group, name, kind: 'info', expected: '(informational)', actual, status: 'INFO', notice }); + } + + private push(line: LineResult): void { + this.lines.push(line); + const mark = line.status === 'FAIL' ? 'FAIL' : line.status; + console.error(`[matrix] ${mark.padEnd(15)} ${line.id} — ${line.name} :: ${line.actual}`); + } + + get hardFailures(): LineResult[] { + return this.lines.filter(l => l.kind === 'hard' && l.status === 'FAIL'); + } + + print(header: string): void { + const out: string[] = []; + out.push('', '═'.repeat(100), `RIGHTS MATRIX — ${header}`, '═'.repeat(100)); + let group = ''; + for (const l of this.lines) { + if (l.group !== group) { group = l.group; out.push('', `── ${group} ${'─'.repeat(Math.max(0, 95 - group.length))}`); } + out.push(`${l.status.padEnd(15)} ${l.id} ${l.name}`); + out.push(`${' '.repeat(16)}expected: ${l.expected}`); + out.push(`${' '.repeat(16)}actual: ${l.actual}`); + if (l.evidence) out.push(`${' '.repeat(16)}evidence: ${l.evidence}`); + if (l.finding) out.push(`${' '.repeat(16)}finding: ${l.finding}`); + if (l.notice) out.push(`${' '.repeat(16)}!!! NOTICE: ${l.notice}`); + } + const count = (s: LineStatus): number => this.lines.filter(l => l.status === s).length; + const notices = this.lines.filter(l => l.notice); + out.push('', '═'.repeat(100)); + out.push(`hard lines: ${this.lines.filter(l => l.kind === 'hard').length} — PASS ${count('PASS')}, FAIL ${count('FAIL')}; ` + + `KNOWN-DEVIATION ${count('KNOWN-DEVIATION')}, KNOWN-DEFECT ${count('KNOWN-DEFECT')}, MANUAL-CHECK ${count('MANUAL-CHECK')}, INFO ${count('INFO')}`); + for (const l of this.hardFailures) out.push(`FAILED: ${l.id} — ${l.name}${l.finding ? ` [${l.finding.split(':')[0]}]` : ''}`); + for (const l of notices) { + out.push('', '!'.repeat(100), `!!! ${l.id} (${l.status}): ${l.notice}`, '!'.repeat(100)); + } + out.push(`VERDICT: ${this.hardFailures.length === 0 ? 'PASS — every hard line holds' : `FAIL — ${this.hardFailures.length} hard line(s) failed`}`); + out.push('═'.repeat(100)); + console.log(out.join('\n')); + } +} + +/** Throw a named prerequisite failure (the line then FAILs, it is never skipped). */ +export function need(value: T | undefined | null | false, what: string): T { + if (value === undefined || value === null || value === false) throw new Error(`prerequisite missing: ${what}`); + return value; +} + +/** Is `status` one of the explicit refusals `allowed`? 0 (unreachable), 404, 5xx never are. */ +export const refused = (status: number, allowed: number[]): boolean => allowed.includes(status); + +// ── HTTP ────────────────────────────────────────────────────────────────── + +export interface HttpResult { status: number; text: string; headers: Headers; error?: string } + +/** + * One request, bounded. A network failure or timeout comes back as status 0 + * with the error text — never thrown, never a status a refusal could match. + */ +export async function http( + method: string, + url: string, + opts: { token?: string; body?: string; contentType?: string; accept?: string; timeoutMs?: number } = {}, +): Promise { + const headers = new Headers({ Accept: opts.accept ?? 'application/ld+json' }); + if (opts.token) headers.set('Authorization', `Bearer ${opts.token}`); + if (opts.contentType) headers.set('Content-Type', opts.contentType); + try { + const r = await fetch(url, { method, headers, body: opts.body, signal: AbortSignal.timeout(opts.timeoutMs ?? 15000) }); + return { status: r.status, text: await r.text(), headers: r.headers }; + } catch (e) { + return { status: 0, text: '', headers: new Headers(), error: errorText(e) }; + } +} + +export const brief = (r: HttpResult): string => + r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}${r.text ? ` ${r.text.replace(/\s+/g, ' ').slice(0, 160)}` : ''}`; + +export function parseJson(text: string): JsonRecord | undefined { + try { + const v: unknown = JSON.parse(text); + return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as JsonRecord) : undefined; + } catch { + return undefined; + } +} + +export const arrayOf = (value: unknown): unknown[] => + value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]; + +/** The member URIs of an attendees collection body (`items`, no `totalItems` on this platform). */ +export function itemUris(record: JsonRecord | undefined): string[] { + if (!record) return []; + return arrayOf(record['items'] ?? record['as:items']).map(linkId).filter((u): u is string => !!u); +} + +export const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); + +/** Poll `probe` until `done` holds or the bound elapses; returns the last value either way. */ +export async function poll(probe: () => Promise, done: (v: T) => boolean, ms: number, every = 1000): Promise { + const end = Date.now() + ms; + let v = await probe(); + while (!done(v) && Date.now() < end) { + await sleep(every); + v = await probe(); + } + return v; +} + +// ── Accounts ────────────────────────────────────────────────────────────── + +/** + * `podLogin` with retries: a fresh pod signup can outlive the helper's 15 s + * timeout while still succeeding server-side, so a retry logs in. + */ +export async function loginWithRetry(config: ApodsStackConfig, username: string, password: string): Promise { + let last: unknown; + for (let attempt = 0; attempt < 4; attempt++) { + try { + return await podLogin(config, username, password); + } catch (e) { + last = e; + await sleep(5000); + } + } + throw new Error(`podLogin(${username}) failed after retries: ${errorText(last)}`); +} + +/** + * Programmatic app install (the smoke's install step): register the app ACTOR + * URI with every access need of its group, then wait for the registered Event + * container to exist. + */ +export async function installApp(config: ApodsStackConfig, session: ApodsSession): Promise<{ ok: boolean; actual: string }> { + const description = await readResource(undefined, `${config.backendUrl}/app`); + const appUri = need(recordUri(description), 'app actor URI (id of GET /app)'); + const groupUri = need(linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI'); + const group = await readResource(undefined, groupUri); + const body = JSON.stringify({ + appUri, + acceptedAccessNeeds: arrayOf(group['interop:hasAccessNeed']), + acceptedSpecialRights: arrayOf(group['apods:hasSpecialRights']), + }); + const reg = await http('POST', `${config.podProviderUrl}/.auth-agent/app-registrations/register`, { + token: session.token, body, contentType: 'application/json', accept: 'application/json', timeoutMs: 30000, + }); + if (reg.status < 200 || reg.status >= 300) return { ok: false, actual: `register → ${brief(reg)}` }; + const container = await poll( + () => http('GET', `${session.storage}/as/event`, { token: session.token }), + r => r.status === 200, 30000, 500, + ); + return { ok: container.status === 200, actual: `register → HTTP ${reg.status}; ${session.storage}/as/event → ${brief(container).slice(0, 40)}` }; +} + +/** The profile document an actor links (`url`), read from the actor document. */ +export async function profileUriOf(webId: string): Promise { + const r = await http('GET', webId); + return linkId(parseJson(r.text)?.['url']); +} + +// ── Browser drives ──────────────────────────────────────────────────────── + +export interface UiHttpError { who: string; url: string; status: number; method: string; body: string } +export interface UiPageError { who: string; url: string; message: string; stack: string } + +export interface DriveContext { + app: string; + podFront: string; + hops: string[]; + errors: UiHttpError[]; + consoleErrors: string[]; + /** Uncaught exceptions thrown in a page, with the page URL at the time. */ + pageErrors: UiPageError[]; + deadline: number; +} + +const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.'); + +export const bodyText = (page: Page): Promise => + page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim()); + +/** Record every ≥400 response, console error and uncaught page error of a page, tagged by user. */ +export function wire(page: Page, who: string, ctx: DriveContext): void { + page.on('response', r => { + if (r.status() >= 400) { + const method = r.request()?.method?.() ?? '?'; + r.text() + .then(b => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: b.slice(0, 300) })) + .catch(() => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: '(unreadable)' })); + } + }); + page.on('console', m => { if (m.type() === 'error') ctx.consoleErrors.push(`[${who}] ${m.text().slice(0, 300)}`); }); + page.on('pageerror', e => ctx.pageErrors.push({ who, url: page.url(), message: String(e.message).slice(0, 300), stack: String(e.stack ?? '').slice(0, 600) })); +} + +/** What a UI drive is EXPECTED to be refused, per user — every entry an exact URL. */ +export interface ExpectedRefusals { + /** This run's profile documents: a profile answers only whom it grants (the Alice/Bob rule). */ + profiles: ReadonlySet; + /** Each user's OWN `pim:preferencesFile`: read by the app with its app token, which the pod does not grant (VERIFIED 2026-10-13). */ + ownPreferences: ReadonlyMap; +} + +/** + * The expected ≥400 responses of a UI drive, each an exact request: the pod's + * push channel creation (POST …/.notifications/WebSocketChannel2023 → 403), + * a GET 403 on one of this run's profile documents, and a GET 403 on the + * signed-in user's OWN preferences file. Anything else — another 403 under + * `/data/` included — is unexpected. + */ +export function benignUiError(e: UiHttpError, expected: ExpectedRefusals): string | null { + if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) return 'push-channel creation 403'; + if (e.method === 'GET' && e.status === 403 && expected.profiles.has(e.url)) return 'profile-document read 403 (WAC)'; + if (e.method === 'GET' && e.status === 403 && expected.ownPreferences.get(e.who) === e.url) return 'own preferences-file read 403 (app token not granted)'; + return null; +} + +/** + * The uncaught page errors explained by an expected refusal: the live + * provider's `subscribe` rejects with "Forbidden" when the pod refuses its + * push channel — exactly one per refused channel POST of the same user + * (VERIFIED 2026-10-13: Bob 2/2, Alice 4/4, Carol 4/4). The throw site is + * identified by its two top stack frames, learnt from errors whose async stack + * still reaches `subscribe` (half of them are cut before that frame, from the + * very same site). Every other page error, and any surplus, is unexpected. + */ +export function splitPageErrors(errors: UiPageError[], http: UiHttpError[]): { expected: UiPageError[]; unexpected: UiPageError[] } { + const channelRefusals = new Map(); + for (const e of http) { + if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) { + channelRefusals.set(e.who, (channelRefusals.get(e.who) ?? 0) + 1); + } + } + const site = (e: UiPageError): string => + e.stack.split('\n').slice(1, 3).map(l => l.trim().replace(/^at async /, 'at ')).join(' < '); + const subscribeSites = new Set(errors.filter(e => e.message === 'Forbidden' && /\bsubscribe\b/.test(e.stack)).map(site)); + const expected: UiPageError[] = []; + const unexpected: UiPageError[] = []; + for (const e of errors) { + const budget = channelRefusals.get(e.who) ?? 0; + if (e.message === 'Forbidden' && subscribeSites.has(site(e)) && budget > 0) { + channelRefusals.set(e.who, budget - 1); + expected.push(e); + } else { + unexpected.push(e); + } + } + return { expected, unexpected }; +} + +/** A user's own preferences file (`pim:preferencesFile` of the actor document, read with her token). */ +export async function preferencesFileOf(session: ApodsSession): Promise { + const r = await http('GET', session.webId, { token: session.token }); + return linkId(parseJson(r.text)?.['pim:preferencesFile']); +} + +export interface LoginOutcome { ok: boolean; detail: string } + +/** + * App picker → pod login → consent → back in the app shell, as a sequence of + * OBSERVED facts — never a fixed delay, never the mere absence of the picker. + * + * The real hop sequence (observed 2026-10-13): the sign-in click leaves the + * app for the pod's login form (:5000/login); after the credentials the + * browser comes BACK to the app's OIDC callback (:4000/login?code…), and only + * then — when the app is not yet registered on this pod — does the app send it + * to the pod's consent screen (:5000/authorize), which navigates a second time + * right after it opens. Accept leads back to the app, then to the shell. + * + * H1 (false "shell reached"): while the app is on its own origin between those + * hops it renders neither the picker nor the shell. The shell therefore counts + * only once the browser has been to the pod, the URL is past /login, and the + * bottom navigation is visible with no picker. + * H2 (accept never landing): Accept is clicked only once it has been attached, + * visible and enabled for a full second (the page's second navigation is over), + * and the click is then VERIFIED by the page leaving the consent screen; if it + * does not, the drive fails with what the page showed — it never re-clicks. + */ +export async function driveLogin(page: Page, who: string, username: string, password: string, ctx: DriveContext): Promise { + const t0 = Date.now(); + const at = (): string => `+${((Date.now() - t0) / 1000).toFixed(1)}s`; + const hop = (h: string): void => { ctx.hops.push(`[${who}] ${at()} ${h}`); }; + page.on('framenavigated', f => { if (f === page.mainFrame()) hop(`→ ${clean(f.url())}`); }); + const fail = async (why: string): Promise => { + const text = (await bodyText(page)).slice(0, 240); + hop(`FAIL ${why} | at ${clean(page.url())} | page: ${text}`); + return { ok: false, detail: `${why} (at ${clean(page.url())}; page: ${text.slice(0, 120)})` }; + }; + const onApp = (): boolean => page.url().startsWith(ctx.app); + const accept = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i }); + const consentReady = async (): Promise => + page.url().startsWith(`${ctx.podFront}/authorize`) + && (await accept.first().isVisible().catch(() => false)) + && (await accept.first().isEnabled().catch(() => false)); + const shellReady = async (): Promise => + onApp() && !new URL(page.url()).pathname.startsWith('/login') + && (await page.getByText('Découvrir', { exact: true }).first().isVisible().catch(() => false)) + && (await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 1)) === 0; + + await page.goto(`${ctx.app}/`, { waitUntil: 'domcontentloaded' }); + const signIn = page.getByRole('button', { name: /Se connecter avec localhost:3000/i }); + if (!(await signIn.first().waitFor({ state: 'visible', timeout: 30000 }).then(() => true, () => false))) return fail('the sign-in button never appeared'); + await signIn.first().click(); + if (!(await page.waitForURL(u => !u.href.startsWith(ctx.app), { timeout: 30000 }).then(() => true, () => false))) { + return fail('the sign-in click never left the app for the pod'); + } + + const userField = page.locator('input[name="username"]'); + if (!(await userField.waitFor({ state: 'visible', timeout: 30000 }).then(() => true, () => false))) return fail('the pod login form never appeared'); + await userField.fill(username); + await page.locator('input[name="password"]').fill(password); + const submit = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i }); + await ((await submit.count()) ? submit.first() : page.locator('button[type=submit]').first()).click(); + hop('credentials submitted'); + + // After login: the consent screen (app not yet registered here) or straight to the shell (already registered). + const next = await poll( + async () => ((await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'), + v => v !== 'pending', 60000, 250, + ); + if (next === 'pending') return fail('after login, neither the consent screen nor the app shell appeared'); + if (next === 'consent') { + // Stable for a full second (4 consecutive observations): the second navigation of the page is over. + let stable = 0; + const settleEnd = Date.now() + 15000; + while (stable < 4 && Date.now() < settleEnd) { + stable = (await consentReady()) ? stable + 1 : 0; + await page.waitForTimeout(250); + } + if (stable < 4) return fail('the consent Accept button never stayed visible and enabled'); + hop('consent ready (Accept attached, visible, enabled for 1 s)'); + await accept.first().click(); + hop('accept clicked'); + if (!(await page.waitForURL(u => !u.href.startsWith(`${ctx.podFront}/authorize`), { timeout: 30000 }).then(() => true, () => false))) { + return fail('Accept was clicked but the page never left the consent screen'); + } + hop('left the consent screen'); + if ((await poll(shellReady, v => v, 45000, 250)) !== true) return fail('back from consent, the app shell never appeared'); + } + hop(`shell reached (bottom navigation, no picker) via ${next === 'consent' ? 'consent' : 'an existing registration'}`); + return { ok: true, detail: `shell reached in ${at()} via ${next === 'consent' ? 'the consent screen' : 'an existing registration (no consent asked)'}` }; +} + +/** + * Is the app installed on this user's pod? The registered Event container + * exists only once the app registration went through (consent or programmatic + * install). Polled: the registration is confirmed asynchronously. + */ +export async function installedContainer(session: ApodsSession, timeoutMs = 30000): Promise { + const uri = `${session.storage}/as/event`; + const r = await poll(() => http('GET', uri, { token: session.token }), v => v.status === 200, timeoutMs, 1000); + return r.status === 200 + ? { ok: true, actual: `${clean(uri)} → HTTP 200` } + : { ok: false, actual: `${clean(uri)} → ${r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}`} after ${timeoutMs / 1000} s — the app is NOT installed on this pod (consent never completed / registration missing)` }; +} + +/** The banked event-creation drive; the event URI is read off the detail route it lands on. */ +export async function createEventUi(page: Page, app: string, title: string): Promise<{ eventUri?: string; finalUrl: string }> { + 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: 10000 }).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: 5000 }).catch(() => {}); + await page.waitForTimeout(1200); + const next = page.getByRole('button', { name: /Suivant/ }); + if (await next.count().catch(() => 0)) await next.click({ timeout: 5000 }).catch(() => {}); + await page.waitForTimeout(1200); + await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 5000 }).catch(() => {}); + const landed = await poll(async () => page.url(), u => /\/events\/[^/]+$/.test(new URL(u).pathname) && !u.includes('/events/new'), 15000, 500); + const m = landed.startsWith(`${app}/events/`) ? /\/events\/([^/?#]+)/.exec(landed.slice(app.length)) : null; + const segment = m?.[1]; + return { eventUri: segment && segment !== 'new' ? decodeURIComponent(segment) : undefined, finalUrl: landed }; +} + +/** The banked meeting-point drive, from the event detail page it starts on. */ +export async function createMeetingPointUi(page: Page, title: string): Promise { + const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i }); + await propose.first().waitFor({ timeout: 15000 }).catch(() => {}); + if (!(await propose.count().catch(() => 0))) return false; + await propose.first().click().catch(() => {}); + await page.waitForTimeout(1500); + await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill(title, { timeout: 10000 }).catch(() => {}); + await page.getByRole('button', { name: /Créer le point de rencontre/ }).click({ timeout: 5000 }).catch(() => {}); + const txt = await poll(() => bodyText(page), t => t.includes(title), 15000, 500); + return txt.includes(title) && !/pas pu/i.test(txt); +} diff --git a/scripts/e2e-rights-matrix.ts b/scripts/e2e-rights-matrix.ts new file mode 100644 index 0000000..f8f7052 --- /dev/null +++ b/scripts/e2e-rights-matrix.ts @@ -0,0 +1,814 @@ +/** + * The rights matrix, end to end, on the REAL local ActivityPods stack: three + * fresh users per run — Bob (organizer), Alice (another user), Carol (a third + * user) — plus anonymous, every feature exercised with positive AND negative + * access assertions. This script IS the acceptance gate for public data, + * discovery and participation: a human no longer validates them one by one. + * + * HOW IT DRIVES. The browser where UI truth matters (Bob and Alice sign in + * through the pod's login + consent; Bob creates the event and TWO meeting + * points through the app; Alice finds the event in Découvrir, opens it, joins + * and leaves both meeting points by tapping; Carol, a non-contact, reads the + * counts her own view renders). Direct HTTP with each user's own token for the + * access-control lines (faster, and precise about statuses). Carol installs the + * app programmatically (the smoke's registration call), then signs in. Setup + * asserts each user's registered Event container answers 200: a sign-in that + * never installed the app fails AT setup, not as a 400 twenty lines later. + * + * DEFENSIVE ONLY (user decision 2026-10-13). No line sends a forged or spoofed + * activity, nor writes spoofed data, to the live stack. Such checks are + * MANUAL-CHECK lines naming where they are covered (backend unit tests). + * + * THE REPORT. Every line is a named assertion with expected vs actual: + * PASS / FAIL — hard lines; any FAIL makes the exit code non-zero. + * KNOWN-DEVIATION — a tracked, decided deviation from the domain + * doctrine (attendee lists are world-readable). + * KNOWN-DEFECT — a tracked platform defect (anonymous PATCH on an + * attendees collection is accepted). + * MANUAL-CHECK — not exercised live (see DEFENSIVE ONLY above). + * Tracked lines never fail the run, but print a loud NOTICE the day their + * behaviour changes, so they get promoted to hard assertions. + * + * NEGATIVE LINES pass only on an explicit refusal: 401 or 403 for an anonymous + * request, 403 for an authenticated one. A 404, a 5xx or an unreachable host + * (status 0) is never a refusal. Each negative line that could be passed by a + * broken probe has a CONTROL line on the opposite state (Alice reads her own + * profile; a twin event WITH public intent is readable and listed; Bob reads + * Alice's profile only after her join; the owner's own container POST lands). + * + * Lines are independent: a failure is recorded and the run continues; a line + * whose prerequisite is missing FAILs with the name of what is missing. + * + * 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. + */ + +import { writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join as joinPath } from 'node:path'; +import { chromium, type Browser, type Page } from 'playwright'; +import { + attendeesCollectionUri, + linkId, + recordUri, + type ApodsSession, + type ApodsStackConfig, + type JsonRecord, +} from '../src/shared/support/activitypodsStack'; +import { + Matrix, + arrayOf, + benignUiError, + bodyText, + brief, + createEventUi, + createMeetingPointUi, + driveLogin, + http, + installApp, + installedContainer, + itemUris, + loginWithRetry, + need, + parseJson, + poll, + profileUriOf, + refused, + sleep, + splitPageErrors, + preferencesFileOf, + wire, + type CheckOutcome, + type DriveContext, +} from './e2e-rights-matrix-support'; + +const strip = (u: string): string => u.replace(/\/+$/, ''); +const BACKEND = strip(process.env.BACKEND_URL || 'http://localhost:3003'); +const APP = strip(process.env.APP_URL || 'http://localhost:4000'); +const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000'); +const config: ApodsStackConfig = { + podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'), + backendUrl: BACKEND, +}; + +const TS = Date.now(); +const PASS = 'Test1test'; +const USERS = { bob: `rmbob${TS}`, alice: `rmalice${TS}`, carol: `rmcarol${TS}` }; +const TITLE_E = `Matrice droits ${TS}`; +const TITLE_M = `Rdv matrice ${TS}`; +const TITLE_M2 = `Rdv second ${TS}`; +const TITLE_P = `Prive matrice ${TS}`; +const TITLE_T = `Public jumeau ${TS}`; + +const PLATFORM_CONTEXT = 'https://activitypods.org/context.json'; +const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'; +const ANON = [401, 403]; +const AUTHN = [403]; + +const m = new Matrix(); +const ui: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: Date.now() + 15 * 60 * 1000 }; + +/** Everything the lines share; each field is set by the line that establishes it. */ +const S: { + bob?: ApodsSession; alice?: ApodsSession; carol?: ApodsSession; + /** M and its attendees A are the first meeting point; M2 / A2 the second. */ + E?: string; M?: string; A?: string; M2?: string; A2?: string; P?: string; T?: string; + bobProfile?: string; aliceProfile?: string; carolProfile?: string; + /** Each user's own preferences file, keyed by the UI drive's tag (bob/alice/carol). */ + preferences: Map; + alicePage?: Page; carolPage?: Page; + /** Set only by the line that SAW Alice's WebID land in the collection. */ + joined: { M?: boolean; M2?: boolean }; + before?: { participants: number; mp: number; mp2: number }; + snapshot?: { E: string; M: string }; +} = { joined: {}, preferences: new Map() }; + +// ── Small readers ───────────────────────────────────────────────────────── + +async function catalogIds(): Promise<{ ids: string[]; items: JsonRecord[]; raw: string }> { + const r = await http('GET', `${BACKEND}/catalog`, { accept: 'application/json' }); + if (r.status !== 200) throw new Error(`GET /catalog → ${brief(r)}`); + const body = parseJson(r.text); + const items = arrayOf(body?.['orderedItems'] ?? body?.['items'] ?? body?.['as:orderedItems']) + .filter((x): x is JsonRecord => typeof x === 'object' && x !== null); + return { ids: items.map(i => recordUri(i)).filter((u): u is string => !!u), items, raw: r.text }; +} + +async function attendees(uri: string, token?: string): Promise<{ status: number; items: string[]; brief: string }> { + const r = await http('GET', uri, { token }); + return { status: r.status, items: r.status === 200 ? itemUris(parseJson(r.text)) : [], brief: brief(r) }; +} + +const ctxLink = (rec: JsonRecord): string | undefined => linkId(rec['as:context'] ?? rec['context']); + +/** + * The card of one meeting point on the event detail page: the deepest element + * holding both its title and a button (ancestors precede descendants in + * document order, so `.last()` is the card itself). + */ +const mpCard = (page: Page, title: string) => + page.locator('div').filter({ has: page.getByText(title, { exact: true }) }).filter({ has: page.getByRole('button') }).last(); + +const countIn = (text: string): number | undefined => { + const k = /👥\s*(\d+)\s+inscrits?/i.exec(text); + return k ? Number(k[1]) : undefined; +}; + +/** UI counts on the event detail page: header "PARTICIPANTS (n)" and each meeting point's "👥 k inscrit(s)". */ +async function detailCounts(page: Page): Promise<{ participants?: number; mp?: number; mp2?: number; text: string }> { + const text = await bodyText(page); + const p = /participants \((\d+)\)/i.exec(text); + const card = async (title: string): Promise => + countIn(await mpCard(page, title).innerText({ timeout: 2000 }).catch(() => '')); + return { participants: p ? Number(p[1]) : undefined, mp: await card(TITLE_M), mp2: await card(TITLE_M2), text }; +} +const showCounts = (c: { participants?: number; mp?: number; mp2?: number }): string => + `PARTICIPANTS (${c.participants}), M ${c.mp} inscrit, M2 ${c.mp2} inscrit`; + +/** + * The summed counter of E read from the data: every meeting point the catalog + * links to E, each attendees collection read anonymously. A read that does not + * answer 200 is an ERROR, never "0 items". + */ +async function summedCounter(): Promise<{ mps: string[]; per: Map; sum: number }> { + const E = need(S.E, 'E'); + const mps = (await catalogIds()).items.filter(i => ctxLink(i) === E); + const per = new Map(); + let sum = 0; + for (const mp of mps) { + const uri = need(recordUri(mp), 'a meeting point id in the catalog'); + const a = await attendees(need(attendeesCollectionUri(mp), `attendees of ${uri}`)); + if (a.status !== 200) throw new Error(`attendees of ${uri} → ${a.brief} (a failed read is never counted as 0)`); + per.set(uri, a.items); + sum += a.items.length; + } + return { mps: mps.map(i => recordUri(i) ?? '?'), per, sum }; +} + +const PUT_BODY = (id: string): string => + JSON.stringify({ '@context': PLATFORM_CONTEXT, id, type: 'Event', name: `HIJACKED ${TS}` }); +const POST_BODY = JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Note', content: `HIJACKED ${TS}` }); +const PATCH_BODY = (id: string): string => + `INSERT DATA { <${id}> "HIJACKED ${TS}" . }`; + +/** A write attempt that must be refused with one of `allowed`. */ +function refusedWrite( + id: string, name: string, method: 'PUT' | 'POST' | 'PATCH' | 'DELETE', who: 'anonymous' | 'alice', target: () => string, group: string, + opts: { body?: string } = {}, +): Promise { + const allowed = who === 'anonymous' ? ANON : AUTHN; + return m.hard(id, group, name, `refused ${allowed.join('/')}`, async () => { + const uri = target(); + const token = who === 'alice' ? need(S.alice, 'Alice session').token : undefined; + const r = method === 'PUT' ? await http('PUT', uri, { token, body: opts.body ?? PUT_BODY(uri), contentType: 'application/ld+json' }) + : method === 'POST' ? await http('POST', uri, { token, body: opts.body ?? POST_BODY, contentType: 'application/ld+json' }) + : method === 'PATCH' ? await http('PATCH', uri, { token, body: opts.body ?? PATCH_BODY(uri), contentType: 'application/sparql-update' }) + : await http('DELETE', uri, { token }); + return { ok: refused(r.status, allowed), actual: brief(r) }; + }); +} + +/** + * POST on a NON-container resource is rejected before any rights check, for + * every agent — the owner included (VERIFIED 2026-10-12: Bob's own POST on E → + * 400 with JSON-LD, 500 with Turtle; E unchanged). It is not a write path, so + * the matrix's POST-on-E/M lines are INFORMATIONAL (orchestrator decision + * 2026-10-12): each shows its status beside the owner's own POST on E. The + * POST requests still fire before `write.unchanged`, which fails on any byte + * they would change. The rights intent is asserted by the strict + * container-POST lines and the PUT/PATCH/DELETE lines. + */ +const POST_ON_RESOURCE_NAME = (who: string, target: string): string => + `${who} POST on ${target} — method-not-applicable: POST on a non-container resource → 400 for every agent, owner included; ` + + 'the POST rights intent is asserted by write.*-POST-container (anonymous/Alice refused, owner 201) and the PUT/PATCH/DELETE lines'; + +/** Fire a POST on a non-container resource and keep its status for the informational line. */ +async function postOnResource(who: 'anonymous' | 'alice', target: () => string): Promise { + try { + const uri = target(); + const token = who === 'alice' ? need(S.alice, 'Alice session').token : undefined; + const r = await http('POST', uri, { token, body: POST_BODY, contentType: 'application/ld+json' }); + return String(r.status); + } catch (e) { + return `not observed: ${String(e).slice(0, 120)}`; + } +} + +// ── The matrix ──────────────────────────────────────────────────────────── + +/** A UI sign-in line: the drive's own verdict, never the absence of the picker. */ +function signIn(id: string, who: 'bob' | 'alice' | 'carol', label: string, page: Page, onOk: () => void): Promise { + return m.hard(id, 'setup', `${label} signs in to the app: pod login → consent (if not yet installed) → app shell`, 'left the app for the pod, came back, bottom navigation shown, no picker', async () => { + need(S[who], `${label} account`); + const r = await driveLogin(page, who, USERS[who], PASS, ui); + if (r.ok) onOk(); + return { ok: r.ok, actual: r.detail }; + }); +} + +/** The install assertion: the registered Event container answers 200 for this user. */ +function installed(id: string, who: 'bob' | 'alice' | 'carol', label: string): Promise { + return m.hard(id, 'setup', `${label}'s pod has the app installed`, 'her/his as/event container answers 200 (else: the sign-in never installed the app)', async (): Promise => + installedContainer(need(S[who], `${label} account`))); +} + +/** Find a meeting point of E in Bob's container by its title; polled (the write lands asynchronously). */ +async function findMeetingPoint(title: string): Promise { + const bob = need(S.bob, 'Bob account'); + const E = need(S.E, 'event E'); + return poll(async () => { + const c = parseJson((await http('GET', `${bob.storage}/as/event`, { token: bob.token })).text); + for (const entry of arrayOf(c?.['ldp:contains'] ?? c?.['contains'])) { + const uri = linkId(entry); + if (!uri || uri === E) continue; + const rec = parseJson((await http('GET', uri, { token: bob.token })).text); + if (rec && ctxLink(rec) === E && JSON.stringify(rec).includes(title)) return uri; + } + return undefined; + }, v => !!v, 20000); +} + +async function attachedCollection(M: string): Promise { + const bob = need(S.bob, 'Bob account'); + return poll(async () => attendeesCollectionUri(parseJson((await http('GET', M, { token: bob.token })).text) ?? {}), v => !!v, 20000); +} + +async function setup(browser: Browser): Promise { + const G = 'setup'; + await m.hard('setup.bob-account', G, `Bob (${USERS.bob}) signs up / in on the pod`, 'session with outbox + storage', async () => { + S.bob = await loginWithRetry(config, USERS.bob, PASS); + return { ok: true, actual: S.bob.webId }; + }); + await m.hard('setup.alice-account', G, `Alice (${USERS.alice}) signs up / in on the pod`, 'session with outbox + storage', async () => { + S.alice = await loginWithRetry(config, USERS.alice, PASS); + return { ok: true, actual: S.alice.webId }; + }); + await m.hard('setup.carol-account', G, `Carol (${USERS.carol}) signs up / in on the pod`, 'session with outbox + storage', async () => { + S.carol = await loginWithRetry(config, USERS.carol, PASS); + return { ok: true, actual: S.carol.webId }; + }); + await m.hard('setup.carol-install', G, 'Carol installs the app (programmatic registration)', 'registered; her as/event container answers 200', async () => + installApp(config, need(S.carol, 'Carol session'))); + + // Bob: consent through the UI, then the event and its two meeting points through the app. + const bobCtx = await browser.newContext(); + const bobPage = await bobCtx.newPage(); + wire(bobPage, 'bob', ui); + await signIn('setup.bob-consent', 'bob', 'Bob', bobPage, () => {}); + await installed('setup.bob-installed', 'bob', 'Bob'); + await m.hard('setup.bob-event', G, `Bob creates event E "${TITLE_E}" through the app`, 'lands on E detail; E is in Bob\'s pod with that name', async () => { + const bob = need(S.bob, 'Bob account'); + const r = await createEventUi(bobPage, APP, TITLE_E); + const E = need(r.eventUri, `an event route after creation (landed on ${r.finalUrl})`); + const rec = parseJson((await http('GET', E, { token: bob.token })).text); + const name = rec?.['name']; + S.E = E; + return { ok: E.startsWith(`${bob.storage}/`) && name === TITLE_E, actual: `${E} name=${JSON.stringify(name)} to=${JSON.stringify(rec?.['to'])}` }; + }); + await m.hard('setup.bob-meeting-point', G, `Bob proposes meeting point M "${TITLE_M}" on E through the app`, 'M in Bob\'s as/event container with as:context = E', async () => { + need(S.E, 'event E'); + const shown = await createMeetingPointUi(bobPage, TITLE_M); + S.M = await findMeetingPoint(TITLE_M); + return { ok: shown && !!S.M, actual: `ui shows it: ${shown}; M = ${S.M ?? '(not found)'}` }; + }); + await m.hard('setup.attendees-collection', G, 'M carries its apods:attendees collection (attached by the backend)', 'collection URI on M', async () => { + S.A = await attachedCollection(need(S.M, 'meeting point M')); + return { ok: !!S.A, actual: S.A ?? '(none after 20 s)' }; + }); + await m.hard('setup.bob-meeting-point-2', G, `Bob proposes a SECOND meeting point M2 "${TITLE_M2}" on E through the app`, 'M2 in Bob\'s as/event container with as:context = E, distinct from M', async () => { + need(S.E, 'event E'); + const shown = await createMeetingPointUi(bobPage, TITLE_M2); + S.M2 = await findMeetingPoint(TITLE_M2); + return { ok: shown && !!S.M2 && S.M2 !== S.M, actual: `ui shows it: ${shown}; M2 = ${S.M2 ?? '(not found)'}` }; + }); + await m.hard('setup.attendees-collection-2', G, 'M2 carries its own apods:attendees collection', 'collection URI on M2, distinct from M\'s', async () => { + S.A2 = await attachedCollection(need(S.M2, 'meeting point M2')); + return { ok: !!S.A2 && S.A2 !== S.A, actual: S.A2 ?? '(none after 20 s)' }; + }); + await bobCtx.close(); + + // Bob, direct HTTP with his token: one event WITHOUT public intent (no `to`), + // and its control twin WITH it — same path, opposite intent. + await m.hard('setup.bob-private-event', G, `Bob POSTs event P "${TITLE_P}" into his as/event container, NO \`to\``, '201 + Location', async () => { + const bob = need(S.bob, 'Bob account'); + const r = await http('POST', `${bob.storage}/as/event`, { + token: bob.token, contentType: 'application/ld+json', + body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name: TITLE_P, startTime: '2026-12-21T18:00:00Z', endTime: '2026-12-21T22:00:00Z' }), + }); + S.P = r.headers.get('Location') ?? undefined; + return { ok: (r.status === 201 || r.status === 200) && !!S.P, actual: `HTTP ${r.status} → ${S.P ?? '(no Location)'}` }; + }); + await m.hard('setup.bob-public-twin', G, `CONTROL: Bob POSTs twin T "${TITLE_T}" the same way, WITH \`to: as:Public\``, '201 + Location', async () => { + const bob = need(S.bob, 'Bob account'); + const r = await http('POST', `${bob.storage}/as/event`, { + token: bob.token, contentType: 'application/ld+json', + body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name: TITLE_T, startTime: '2026-12-21T18:00:00Z', endTime: '2026-12-21T22:00:00Z', to: AS_PUBLIC }), + }); + S.T = r.headers.get('Location') ?? undefined; + return { ok: (r.status === 201 || r.status === 200) && !!S.T, actual: `HTTP ${r.status} → ${S.T ?? '(no Location)'}` }; + }); + + // Alice: consent through the UI; her page carries discovery, join and leave. + const alicePage = await (await browser.newContext()).newPage(); + wire(alicePage, 'alice', ui); + await signIn('setup.alice-consent', 'alice', 'Alice', alicePage, () => { S.alicePage = alicePage; }); + await installed('setup.alice-installed', 'alice', 'Alice'); + + // Carol: already installed programmatically; she signs in to see her own (non-contact) view. + const carolPage = await (await browser.newContext()).newPage(); + wire(carolPage, 'carol', ui); + await signIn('setup.carol-sign-in', 'carol', 'Carol', carolPage, () => { S.carolPage = carolPage; }); + + S.bobProfile = S.bob ? await profileUriOf(S.bob.webId) : undefined; + S.aliceProfile = S.alice ? await profileUriOf(S.alice.webId) : undefined; + S.carolProfile = S.carol ? await profileUriOf(S.carol.webId) : undefined; + for (const who of ['bob', 'alice', 'carol'] as const) { + const session = S[who]; + const pref = session ? await preferencesFileOf(session) : undefined; + if (pref) S.preferences.set(who, pref); + } +} + +async function publicReads(): Promise { + const G = 'public reads (positive)'; + const read = (id: string, name: string, token: () => string | undefined, target: () => string, extra?: (rec: JsonRecord | undefined) => string) => + m.hard(id, G, name, '200', async () => { + const r = await http('GET', target(), { token: token() }); + return { ok: r.status === 200, actual: `HTTP ${r.status}${extra ? ` ${extra(parseJson(r.text))}` : ''}` }; + }); + await read('read.anon-E', 'anonymous GET E', () => undefined, () => need(S.E, 'event E')); + await read('read.anon-M', 'anonymous GET M', () => undefined, () => need(S.M, 'meeting point M')); + await read('read.alice-E', 'Alice GET E (her token)', () => need(S.alice, 'Alice').token, () => need(S.E, 'event E')); + await read('read.alice-M', 'Alice GET M (her token)', () => need(S.alice, 'Alice').token, () => need(S.M, 'meeting point M')); + await m.hard('read.alice-attendees', G, "Alice GET M's attendees (her token)", '200 with an `items` key (count = items.length; no totalItems on this platform)', async () => { + const r = await http('GET', need(S.A, 'attendees collection'), { token: need(S.alice, 'Alice').token }); + const rec = parseJson(r.text); + const hasItemsKey = !!rec && ('items' in rec || 'as:items' in rec); + return { ok: r.status === 200 && hasItemsKey, actual: `HTTP ${r.status} items.length=${itemUris(rec).length} (items key present: ${hasItemsKey})` }; + }); +} + +async function writesRefused(): Promise { + const G = 'writes (negative)'; + const bobRead = async (uri: string): Promise => { + const r = await http('GET', uri, { token: need(S.bob, 'Bob').token }); + if (r.status !== 200) throw new Error(`Bob GET ${uri} → ${brief(r)}`); + return r.text; + }; + try { + S.snapshot = { E: await bobRead(need(S.E, 'E')), M: await bobRead(need(S.M, 'M')) }; + } catch { /* the unchanged line below FAILs with the missing snapshot */ } + const E = (): string => need(S.E, 'event E'); + const M = (): string => need(S.M, 'meeting point M'); + await refusedWrite('write.anon-PUT-E', 'anonymous PUT on E', 'PUT', 'anonymous', E, G); + const postOn: Record = {}; + postOn['anon-E'] = await postOnResource('anonymous', E); + await refusedWrite('write.anon-PATCH-E', 'anonymous PATCH on E', 'PATCH', 'anonymous', E, G); + await refusedWrite('write.anon-PUT-M', 'anonymous PUT on M', 'PUT', 'anonymous', M, G); + postOn['anon-M'] = await postOnResource('anonymous', M); + await refusedWrite('write.anon-PATCH-M', 'anonymous PATCH on M', 'PATCH', 'anonymous', M, G); + await refusedWrite('write.alice-PUT-E', 'Alice PUT on E', 'PUT', 'alice', E, G); + postOn['alice-E'] = await postOnResource('alice', E); + await refusedWrite('write.alice-PATCH-E', 'Alice PATCH on E', 'PATCH', 'alice', E, G); + await refusedWrite('write.alice-PUT-M', 'Alice PUT on M', 'PUT', 'alice', M, G); + postOn['alice-M'] = await postOnResource('alice', M); + await refusedWrite('write.alice-PATCH-M', 'Alice PATCH on M', 'PATCH', 'alice', M, G); + await m.hard('write.unchanged', G, 'E and M are byte-identical after every refused write (read by Bob)', 'identical to the pre-write snapshot', async () => { + const snap = need(S.snapshot, 'pre-write snapshot of E and M'); + const e = await bobRead(E()); + const mm = await bobRead(M()); + return { ok: e === snap.E && mm === snap.M, actual: `E ${e === snap.E ? 'unchanged' : 'CHANGED'}, M ${mm === snap.M ? 'unchanged' : 'CHANGED'}${e.includes('HIJACKED') || mm.includes('HIJACKED') ? ' (HIJACKED marker present)' : ''}` }; + }); + const container = (): string => `${need(S.bob, 'Bob').storage}/as/event`; + const hijack = JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name: `HIJACKED ${TS}` }); + await refusedWrite('write.anon-POST-container', "anonymous POST into Bob's as/event container (owner control: setup.bob-private-event → 201)", 'POST', 'anonymous', container, G, { body: hijack }); + await refusedWrite('write.alice-POST-container', "Alice POST into Bob's as/event container (owner control: setup.bob-private-event → 201)", 'POST', 'alice', container, G, { body: hijack }); + let ownerStatus = 'not observed'; + m.info('write.owner-POST-E', G, 'evidence for the POST-on-resource lines: Bob (owner) POST on his own E', await (async () => { + const r = await http('POST', need(S.E, 'E'), { token: need(S.bob, 'Bob').token, body: POST_BODY, contentType: 'application/ld+json' }).catch(() => undefined); + if (r) ownerStatus = String(r.status); + return r ? `Bob POST on E → ${brief(r)}` : 'not observed'; + })().catch(e => `not observed: ${String(e)}`)); + const posts: Array<[string, string, string, string]> = [ + ['write.anon-POST-E', 'anon-E', 'anonymous', 'E'], + ['write.anon-POST-M', 'anon-M', 'anonymous', 'M'], + ['write.alice-POST-E', 'alice-E', 'Alice', 'E'], + ['write.alice-POST-M', 'alice-M', 'Alice', 'M'], + ]; + for (const [id, key, who, target] of posts) { + const status = postOn[key] ?? 'not observed'; + const premiseHolds = status === ownerStatus && status === '400'; + m.info(id, G, POST_ON_RESOURCE_NAME(who, target), `${who} POST on ${target} → ${status}; owner control: Bob POST on E → ${ownerStatus}`, + premiseHolds ? undefined : 'method-not-applicable no longer holds as observed (status differs from the owner\'s, or is not 400) — re-evaluate whether POST on a resource became a write path'); + } +} + +async function profilePrivacy(): Promise { + const G = 'profile privacy (negative, with control)'; + await m.hard('profile.alice-own', G, 'CONTROL: Alice GET her own profile document (her token)', '200 (the URI is right; refusals below are WAC)', async () => { + const r = await http('GET', need(S.aliceProfile, "Alice's profile URI (actor `url`)"), { token: need(S.alice, 'Alice').token }); + return { ok: r.status === 200, actual: `HTTP ${r.status} ${S.aliceProfile}` }; + }); + await m.hard('profile.alice-reads-bob', G, "Alice GET Bob's profile document (her token)", 'refused 403', async () => { + const r = await http('GET', need(S.bobProfile, "Bob's profile URI"), { token: need(S.alice, 'Alice').token }); + return { ok: refused(r.status, AUTHN), actual: `${brief(r).slice(0, 60)} ${S.bobProfile}` }; + }); + await m.hard('profile.bob-reads-alice-before-join', G, "Bob GET Alice's profile document BEFORE any join (his token)", 'refused 403 (nobody is a contact at start)', async () => { + const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.bob, 'Bob').token }); + return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 60) }; + }); + await m.hard('profile.carol-reads-alice', G, "Carol GET Alice's profile document (her token)", 'refused 403', async () => { + const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.carol, 'Carol').token }); + return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 60) }; + }); + await m.hard('profile.anon-reads-alice', G, "anonymous GET Alice's profile document", 'refused 401/403', async () => { + const r = await http('GET', need(S.aliceProfile, "Alice's profile URI")); + return { ok: refused(r.status, ANON), actual: brief(r).slice(0, 60) }; + }); +} + +async function catalogAndIntent(): Promise { + const G = 'catalog & public intent'; + const listed = (id: string, name: string, target: () => string) => m.hard(id, G, name, 'listed in GET /catalog within 30 s', async () => { + const uri = target(); + const c = await poll(() => catalogIds(), v => v.ids.includes(uri), 30000); + return { ok: c.ids.includes(uri), actual: `${c.ids.includes(uri) ? 'listed' : 'NOT listed'} (catalog has ${c.ids.length} items)` }; + }); + await listed('catalog.E-listed', 'E (created through the app) is listed', () => need(S.E, 'E')); + await listed('catalog.M-listed', 'M (created through the app) is listed', () => need(S.M, 'M')); + await listed('catalog.M2-listed', 'M2 (created through the app) is listed', () => need(S.M2, 'M2')); + await m.hard('catalog.M-context', G, "M's catalog item links E through as:context", 'as:context = E', async () => { + const c = await catalogIds(); + const item = c.items.find(i => recordUri(i) === need(S.M, 'M')); + return { ok: !!item && ctxLink(item) === need(S.E, 'E'), actual: `as:context = ${item ? ctxLink(item) : '(M not in catalog)'}` }; + }); + await listed('catalog.T-listed', 'CONTROL: twin T (direct POST, WITH public intent) is listed', () => need(S.T, 'twin T')); + await m.hard('read.anon-T', G, 'CONTROL: anonymous GET twin T', '200', async () => { + const r = await http('GET', need(S.T, 'twin T')); + return { ok: r.status === 200, actual: `HTTP ${r.status}` }; + }); + await m.hard('private.bob-reads-P', G, 'CONTROL: Bob GET P (his token) — P exists', '200', async () => { + const r = await http('GET', need(S.P, 'private P'), { token: need(S.bob, 'Bob').token }); + return { ok: r.status === 200, actual: `HTTP ${r.status}` }; + }); + await m.hard('private.anon-P', G, 'anonymous GET P (no public intent)', 'refused 401/403', async () => { + const r = await http('GET', need(S.P, 'private P')); + return { ok: refused(r.status, ANON), actual: brief(r).slice(0, 80) }; + }); + await m.hard('private.P-not-in-catalog', G, 'P is never listed in GET /catalog', 'absent once its later twin T is listed, and 5 s after', async () => { + const P = need(S.P, 'private P'); + const T = need(S.T, 'twin T'); + const settled = await poll(() => catalogIds(), v => v.ids.includes(T), 30000); + if (!settled.ids.includes(T)) throw new Error('the window never settled: twin T was not listed either'); + await sleep(5000); + const c = await catalogIds(); + return { ok: !c.ids.includes(P) && !c.raw.includes(TITLE_P), actual: c.ids.includes(P) ? 'LISTED' : c.raw.includes(TITLE_P) ? 'title present in catalog body' : 'absent' }; + }); +} + +async function discoveryUi(): Promise { + const G = 'discovery (Alice, UI)'; + let text = ''; + await m.hard('discover.E-listed', G, "Alice's Découvrir (/events) lists Bob's E (title match)", `"${TITLE_E}" shown`, async () => { + const page = need(S.alicePage, "Alice's signed-in page"); + await page.goto(`${APP}/events`, { waitUntil: 'domcontentloaded' }); + text = await poll(() => bodyText(page), t => t.includes(TITLE_E) && t.includes(TITLE_T), 30000, 1000); + return { ok: text.includes(TITLE_E), actual: text.includes(TITLE_E) ? 'shown' : `absent; page: ${text.slice(0, 160)}` }; + }); + await m.hard('discover.T-listed', G, "CONTROL: Alice's Découvrir lists the public twin T", `"${TITLE_T}" shown`, async () => + ({ ok: text.includes(TITLE_T), actual: text.includes(TITLE_T) ? 'shown' : 'absent' })); + await m.hard('discover.P-absent', G, "Alice's Découvrir never shows Bob's private P", `"${TITLE_P}" absent (same render that shows E and T)`, async () => { + if (!text.includes(TITLE_E) || !text.includes(TITLE_T)) throw new Error('prerequisite missing: a Découvrir render showing E and T'); + return { ok: !text.includes(TITLE_P), actual: text.includes(TITLE_P) ? 'SHOWN' : 'absent' }; + }); + try { + const c = await catalogIds(); + const ours = new Set([S.E, S.M, S.T].filter(Boolean)); + m.info('discover.fresh-user-empty', G, 'a brand-new user\'s Découvrir is empty of others\' events (sanity)', + `not applicable on a shared stack: the catalog already holds ${c.ids.filter(i => !ours.has(i)).length} items from other users`); + } catch (e) { + m.info('discover.fresh-user-empty', G, 'a brand-new user\'s Découvrir is empty of others\' events (sanity)', `catalog unreadable: ${String(e)}`); + } +} + +/** Tap a meeting point's toggle on the detail page and wait for its card to show `after`. */ +async function tapMeetingPoint(page: Page, title: string, before: RegExp, after: RegExp): Promise { + const card = mpCard(page, title); + await card.getByRole('button', { name: before }).click({ timeout: 10000 }); + const ok = await card.getByRole('button', { name: after }).waitFor({ state: 'visible', timeout: 15000 }).then(() => true, () => false); + return { ok, actual: ok ? `card shows ${after.source}` : `no ${after.source} on the card within 15 s` }; +} + +/** Poll a collection (Alice's token) until it does / does not hold Alice; any non-200 read keeps the line failing. */ +async function aliceIn(collection: string, wanted: boolean): Promise<{ ok: boolean; status: number; items: string[] }> { + const alice = need(S.alice, 'Alice'); + const a = await poll(() => attendees(collection, alice.token), v => v.status === 200 && v.items.includes(alice.webId) === wanted, 30000); + return { ok: a.status === 200 && a.items.includes(alice.webId) === wanted, status: a.status, items: a.items }; +} + +async function join(): Promise { + const G = 'join (Alice, UI)'; + await m.hard('join.open-E', G, 'Alice taps E in Découvrir → its detail renders', 'detail route of E, title shown, M and M2 listed', async () => { + const page = need(S.alicePage, "Alice's signed-in page"); + const E = need(S.E, 'E'); + await page.getByText(TITLE_E, { exact: true }).first().click({ timeout: 10000 }); + const t = await poll(() => bodyText(page), x => x.includes(TITLE_E) && x.includes(TITLE_M) && x.includes(TITLE_M2) && /👥\s*\d+\s+inscrit/i.test(x), 30000); + const onRoute = decodeURIComponent(new URL(page.url()).pathname).endsWith(`/events/${E}`); + return { ok: onRoute && t.includes(TITLE_E) && t.includes(TITLE_M) && t.includes(TITLE_M2), actual: `route ok: ${onRoute}; title: ${t.includes(TITLE_E)}; M: ${t.includes(TITLE_M)}; M2: ${t.includes(TITLE_M2)}${t.includes('non trouvé') ? '; "non trouvé" shown' : ''}` }; + }); + await m.hard('join.before', G, "Before joining, Alice's view counts nobody (fresh event)", 'PARTICIPANTS (0), M 0 inscrit, M2 0 inscrit', async () => { + const c = await poll(() => detailCounts(need(S.alicePage, "Alice's page")), v => v.participants !== undefined && v.mp !== undefined && v.mp2 !== undefined, 15000); + if (c.participants !== undefined && c.mp !== undefined && c.mp2 !== undefined) S.before = { participants: c.participants, mp: c.mp, mp2: c.mp2 }; + return { ok: c.participants === 0 && c.mp === 0 && c.mp2 === 0, actual: showCounts(c) }; + }); + await m.hard('join.tap', G, "Alice taps S'inscrire on M", "M's card turns ✓ Inscrit", async () => + tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M, /^S'inscrire$/, /✓ Inscrit/)); + await m.hard('join.collection', G, "Alice's WebID appears in M's attendees items", 'items ∋ Alice within 30 s', async () => { + const r = await aliceIn(need(S.A, 'attendees of M'), true); + if (r.ok) S.joined.M = true; + return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; + }); + await m.hard('join.ui-counts', G, "Alice's view increments by 1 (event header and M; M2 unchanged)", 'PARTICIPANTS (1), M 1 inscrit, M2 0 inscrit', async () => { + need(S.joined.M, 'the join on M landed (join.collection)'); + const page = need(S.alicePage, "Alice's page"); + const c = await poll(() => detailCounts(page), v => v.participants === 1 && v.mp === 1 && v.mp2 === 0, 20000); + return { ok: c.participants === 1 && c.mp === 1 && c.mp2 === 0, actual: showCounts(c) }; + }); + await m.hard('join.tap-2', G, "Alice ALSO taps S'inscrire on M2 (one person in both meeting points)", "M2's card turns ✓ Inscrit", async () => + tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M2, /^S'inscrire$/, /✓ Inscrit/)); + await m.hard('join.collection-2', G, "Alice's WebID appears in M2's attendees items", 'items ∋ Alice within 30 s', async () => { + const r = await aliceIn(need(S.A2, 'attendees of M2'), true); + if (r.ok) S.joined.M2 = true; + return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; + }); + await m.hard('join.ui-counts-2', G, "Alice's view: the event counts her in each meeting point", 'PARTICIPANTS (2), M 1 inscrit, M2 1 inscrit', async () => { + need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)'); + const page = need(S.alicePage, "Alice's page"); + const c = await poll(() => detailCounts(page), v => v.participants === 2 && v.mp === 1 && v.mp2 === 1, 20000); + return { ok: c.participants === 2 && c.mp === 1 && c.mp2 === 1, actual: showCounts(c) }; + }); + await m.hard('join.bob-resolves-alice', G, "Bob can resolve Alice's name after her join (Bob's token on her profile)", "200 — the join grants the organizer contact-read", async () => { + need(S.joined.M, 'the join on M landed (join.collection)'); + const bob = need(S.bob, 'Bob'); + const r = await poll(() => http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: bob.token }), v => v.status === 200, 20000, 2000); + return { ok: r.status === 200, actual: `${brief(r).slice(0, 120)} (polled 20 s)`, evidence: `GET ${S.aliceProfile} with Bob's token` }; + }, { finding: "PRODUCT-FINDING: joining a meeting point does not grant its organizer read access on the joiner's profile — the organizer cannot resolve who joined" }); + await m.hard('join.carol-cannot-resolve-alice', G, "Carol cannot resolve Alice's name after the join (Carol's token on her profile)", 'refused 403', async () => { + need(S.joined.M, 'the join on M landed (join.collection)'); + const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.carol, 'Carol').token }); + return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 80) }; + }); +} + +async function counterSemantics(): Promise { + const G = 'counter semantics'; + await m.hard('counter.sum', G, + "E's counter is the SUM of its meeting points' item counts — Alice in both M and M2 counts twice (sum 2; max, first or distinct would give 1)", + "data: M=[Alice], M2=[Alice], sum 2; Alice's view PARTICIPANTS (2)", async () => { + need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)'); + const alice = need(S.alice, 'Alice').webId; + const d = await summedCounter(); + const inM = d.per.get(need(S.M, 'M'))?.includes(alice) ?? false; + const inM2 = d.per.get(need(S.M2, 'M2'))?.includes(alice) ?? false; + const ui = await poll(() => detailCounts(need(S.alicePage, "Alice's page")), v => v.participants === 2, 15000); + const parts = [...d.per].map(([u, it]) => `${u.slice(-12)}:${it.length}`).join(', '); + return { ok: d.mps.length === 2 && inM && inM2 && d.sum === 2 && ui.participants === 2, actual: `MPs=${d.mps.length} [${parts}] Alice in M: ${inM}, in M2: ${inM2}; sum=${d.sum}; Alice's UI PARTICIPANTS (${ui.participants})` }; + }); + await m.hard('counter.non-contact-total', G, + "A non-contact (Carol) is shown the TOTAL in her own view, not the count of profiles she can resolve", + "Carol's view of E: PARTICIPANTS (2), M 1 inscrit, M2 1 inscrit — while Alice's profile is refused to her (resolvable: 0)", async () => { + need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)'); + const page = need(S.carolPage, "Carol's signed-in page"); + await page.goto(`${APP}/events/${encodeURIComponent(need(S.E, 'E'))}`, { waitUntil: 'domcontentloaded' }); + const c = await poll(() => detailCounts(page), v => v.participants === 2 && v.mp === 1 && v.mp2 === 1, 30000); + const p = await http('GET', need(S.aliceProfile, "Alice's profile"), { token: need(S.carol, 'Carol').token }); + const anonymousRows = (c.text.match(/Participant inconnu/g) ?? []).length; + return { ok: c.participants === 2 && c.mp === 1 && c.mp2 === 1 && refused(p.status, AUTHN), actual: `Carol's UI: ${showCounts(c)}; anonymous rows shown: ${anonymousRows}; Alice's profile for Carol → HTTP ${p.status}` }; + }); +} + +async function isolation(): Promise { + const G = 'isolation (negative)'; + await m.hard('isolation.alice-own-pod', G, "Alice's own as/event container (her token) does not contain Bob's E or M — cross-user visibility comes only through the catalog", '200, neither E nor M in it', async () => { + const alice = need(S.alice, 'Alice'); + const r = await http('GET', `${alice.storage}/as/event`, { token: alice.token }); + const rec = parseJson(r.text); + const members = arrayOf(rec?.['ldp:contains'] ?? rec?.['contains']).map(linkId).filter((u): u is string => !!u); + const E = need(S.E, 'E'); + const M = need(S.M, 'M'); + const leaked = r.text.includes(E) || r.text.includes(M); + return { ok: r.status === 200 && !!rec && !leaked, actual: `HTTP ${r.status}; ${members.length} member(s); E/M ${leaked ? 'PRESENT' : 'absent'}` }; + }); +} + +async function catalogDeletion(): Promise { + const G = 'catalog deletion'; + m.manual('manual.forged-delete', G, + "A user posting to HER outbox a Delete that embeds a Tombstone of another user's event must not de-index it", + 'NOT exercised live (defensive only, user decision 2026-10-13: no forged activity is sent). Covered by backend unit tests — ' + + 'T05.h catalog.service ownership check (object must lie under the emitting outbox\'s pod) and T05.i verifiedEmit (activity and object re-read from the pod) — ' + + 'plus a manual check recorded in the handoff.'); + await m.hard('catalog.owner-delete-deindexes', G, 'Bob deletes his own public twin T → T leaves /catalog', 'owner DELETE 2xx, then T absent from /catalog within 30 s', async () => { + const T = need(S.T, 'twin T'); + const del = await http('DELETE', T, { token: need(S.bob, 'Bob').token }); + const c = await poll(() => catalogIds(), v => !v.ids.includes(T), 30000); + return { ok: del.status >= 200 && del.status < 300 && !c.ids.includes(T), actual: `DELETE → HTTP ${del.status}; T ${c.ids.includes(T) ? 'still listed' : 'de-indexed'}` }; + }); +} + +async function leave(): Promise { + const G = 'leave (Alice, UI)'; + await m.hard('leave.tap', G, 'Alice taps ✓ Inscrit on M (leave)', "M's card turns back to S'inscrire", async () => { + need(S.joined.M, 'the join on M landed (join.collection) — a leave without a join proves nothing'); + return tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M, /✓ Inscrit/, /^S'inscrire$/); + }); + await m.hard('leave.collection', G, "Alice's WebID disappears from M's attendees items", 'items ∌ Alice within 30 s (read 200)', async () => { + need(S.joined.M, 'the join on M landed (join.collection)'); + const r = await aliceIn(need(S.A, 'attendees of M'), false); + return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; + }); + await m.hard('leave.ui-counts', G, "Alice's view decrements: she is now counted in M2 only", 'PARTICIPANTS (1), M 0 inscrit, M2 1 inscrit', async () => { + need(S.joined.M && S.joined.M2, 'both joins landed'); + const page = need(S.alicePage, "Alice's page"); + const c = await poll(() => detailCounts(page), v => v.participants === 1 && v.mp === 0 && v.mp2 === 1, 20000); + return { ok: c.participants === 1 && c.mp === 0 && c.mp2 === 1, actual: showCounts(c) }; + }); + await m.hard('leave.tap-2', G, 'Alice taps ✓ Inscrit on M2 (leave)', "M2's card turns back to S'inscrire", async () => { + need(S.joined.M2, 'the join on M2 landed (join.collection-2) — a leave without a join proves nothing'); + return tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M2, /✓ Inscrit/, /^S'inscrire$/); + }); + await m.hard('leave.collection-2', G, "Alice's WebID disappears from M2's attendees items", 'items ∌ Alice within 30 s (read 200)', async () => { + need(S.joined.M2, 'the join on M2 landed (join.collection-2)'); + const r = await aliceIn(need(S.A2, 'attendees of M2'), false); + return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; + }); + await m.hard('leave.ui-counts-2', G, "Alice's view is back to its pre-join counts", 'PARTICIPANTS (0), M 0 inscrit, M2 0 inscrit (= join.before)', async () => { + need(S.joined.M && S.joined.M2, 'both joins landed'); + const before = need(S.before, 'pre-join counts'); + const page = need(S.alicePage, "Alice's page"); + const same = (v: { participants?: number; mp?: number; mp2?: number }): boolean => v.participants === before.participants && v.mp === before.mp && v.mp2 === before.mp2; + const c = await poll(() => detailCounts(page), same, 20000); + return { ok: same(c), actual: showCounts(c) }; + }); + await m.hard('leave.sum', G, "E's summed counter is back to its pre-join value (every collection read 200)", 'sum of M and M2 items = 0', async () => { + need(S.joined.M && S.joined.M2, 'both joins landed'); + const d = await summedCounter(); + return { ok: d.mps.length === 2 && d.sum === need(S.before, 'pre-join counts').participants, actual: `MPs=${d.mps.length} sum=${d.sum}` }; + }); +} + +async function deletesRefused(): Promise { + const G = 'deletes (negative)'; + await refusedWrite('delete.anon-E', 'anonymous DELETE E', 'DELETE', 'anonymous', () => need(S.E, 'E'), G); + await refusedWrite('delete.alice-E', 'Alice DELETE E', 'DELETE', 'alice', () => need(S.E, 'E'), G); + await refusedWrite('delete.alice-M', 'Alice DELETE M', 'DELETE', 'alice', () => need(S.M, 'M'), G); + await refusedWrite('delete.alice-attendees', "Alice DELETE M's attendees collection", 'DELETE', 'alice', () => need(S.A, 'attendees'), G); + await refusedWrite('delete.alice-bob-profile', "Alice DELETE Bob's profile document", 'DELETE', 'alice', () => need(S.bobProfile, "Bob's profile"), G); + await m.hard('delete.still-there', G, "E, M, M's attendees and Bob's profile all still exist (read by Bob)", 'all 200', async () => { + const bob = need(S.bob, 'Bob'); + const uris = [need(S.E, 'E'), need(S.M, 'M'), need(S.A, 'attendees'), need(S.bobProfile, "Bob's profile")]; + const st = await Promise.all(uris.map(u => http('GET', u, { token: bob.token }).then(r => r.status))); + return { ok: st.every(s => s === 200), actual: st.join(' / ') }; + }); +} + +async function tracked(): Promise { + const G = 'tracked platform behaviours (non-fatal)'; + await m.known('known-deviation', 'deviation.anon-attendees-read', G, + "anonymous GET M's attendees (doctrine: participation is PROTECTED; platform default: world-readable)", + 'doctrine: refused; tracked as world-readable (user decision 2026-10-12)', async () => { + const r = await http('GET', need(S.A, 'attendees')); + const n = r.status === 200 ? itemUris(parseJson(r.text)).length : 0; + if (r.status === 200) return { ok: true, actual: `HTTP 200 — world-readable (items.length=${n}; members are WebIDs, which embed usernames)` }; + if (refused(r.status, ANON)) return { ok: true, actual: `HTTP ${r.status} — refused`, notice: 'the attendee list is no longer world-readable: the deviation is gone — promote this line to a hard negative assertion' }; + return { ok: false, actual: brief(r), notice: 'neither world-readable nor an explicit refusal — investigate before trusting this line' }; + }); + + await m.known('known-defect', 'defect.anon-attendees-patch', G, + "anonymous PATCH on M's attendees collection (its only foaf:Agent rule is acl:Read)", + 'should be refused 401/403; tracked platform defect: accepted (204)', async () => { + // DEFENSIVE ONLY: the probe is a triple on a test predicate, which the + // collection accepts and silently drops (VERIFIED 2026-10-12: 204, nothing + // written) — no member is spoofed. The status alone is the tripwire: a WAC + // refusal would answer 401/403. That an anonymous `as:items` insert LANDS + // was verified once on 2026-10-12 and is not re-exercised live. + const A = need(S.A, 'attendees'); + const bob = need(S.bob, 'Bob'); + const marker = `anon-patch-tripwire-${TS}`; + const before = itemUris(parseJson((await http('GET', A, { token: bob.token })).text)); + const r = await http('PATCH', A, { body: `INSERT DATA { <${A}> "${marker}" . }`, contentType: 'application/sparql-update' }); + const afterRead = await http('GET', A, { token: bob.token }); + const after = itemUris(parseJson(afterRead.text)); + const wrote = afterRead.text.includes(marker) || after.length !== before.length; + const actual = `HTTP ${r.status}; collection ${wrote ? 'CHANGED' : 'unchanged'} (items ${before.length} → ${after.length})`; + if (r.status >= 200 && r.status < 300 && !wrote) return { ok: true, actual }; + if (refused(r.status, ANON)) return { ok: true, actual, notice: 'anonymous PATCH on an attendees collection is now REFUSED — the platform defect looks fixed: promote this line to a hard negative assertion (refused 401/403)' }; + return { ok: false, actual, notice: `behaviour changed (HTTP ${r.status}, collection ${wrote ? 'changed' : 'unchanged'}) — neither the tracked acceptance nor a refusal: investigate, then decide whether to promote` }; + }); +} + +async function uiHygiene(): Promise { + const G = 'UI drives'; + // Expected refusals are exact URLs: this run's profile documents, and each + // user's OWN preferences file. Any other ≥400 is unexpected. + const profiles = new Set([S.bobProfile, S.aliceProfile, S.carolProfile].filter((u): u is string => !!u)); + const expected = { profiles, ownPreferences: S.preferences }; + const unexpected = ui.errors.filter(e => benignUiError(e, expected) === null); + const seen = new Map(); + for (const e of ui.errors) { + const label = benignUiError(e, expected); + if (label) seen.set(label, (seen.get(label) ?? 0) + 1); + } + await m.hard('ui.no-unexpected-4xx', G, 'No unexpected ≥400 response during the Bob, Alice and Carol UI drives', + 'none beyond: push-channel creation 403, profile-document 403 (this run\'s profiles), own preferences-file 403', async () => { + if (profiles.size < 3 || S.preferences.size < 3) throw new Error('prerequisite missing: the three profile and preferences-file URIs (to tell an expected 403 from any other)'); + return { + ok: unexpected.length === 0, + actual: unexpected.length === 0 + ? `none (expected seen: ${[...seen].map(([k, n]) => `${k} ×${n}`).join(', ') || 'none'})` + : unexpected.map(e => `[${e.who}] ${e.status} ${e.method} ${e.url} ${e.body.slice(0, 80)}`).join(' | '), + }; + }); + const pe = splitPageErrors(ui.pageErrors, ui.errors); + await m.hard('ui.no-page-errors', G, 'No unexpected uncaught page error during the UI drives (app and pod pages)', + 'none beyond the live provider\'s "Forbidden" from subscribe, one per refused push channel of the same user', async () => ({ + ok: pe.unexpected.length === 0, + actual: pe.unexpected.length === 0 + ? `none (expected: ${pe.expected.length} × "Forbidden" from the live provider's subscribe)` + : pe.unexpected.map(e => `[${e.who}] at ${e.url}: ${e.message} | ${e.stack.split('\n').slice(1, 3).join(' ').trim()}`).join(' | '), + })); + m.info('ui.expected-page-errors', G, 'Uncaught "Forbidden" from the live provider\'s subscribe when the pod refuses its push channel (SDK side — to report upstream, never worked around here)', + `${pe.expected.length} seen (${[...new Set(pe.expected.map(e => e.who))].join(', ') || 'none'})`); +} + +(async () => { + console.error(`[matrix] run ${TS}: bob=${USERS.bob} alice=${USERS.alice} carol=${USERS.carol}`); + // No --disable-web-security: the app must work under the browser's real + // cross-origin rules, as it does for a user. + const browser = await chromium.launch({ headless: true }); + try { + await setup(browser); + await publicReads(); + await writesRefused(); + await profilePrivacy(); + await catalogAndIntent(); + await discoveryUi(); + await join(); + await counterSemantics(); + await isolation(); + await catalogDeletion(); + await leave(); + await deletesRefused(); + await tracked(); + } finally { + await uiHygiene(); + await browser.close().catch(() => {}); + } + const file = joinPath(tmpdir(), `rights-matrix-${TS}.json`); + writeFileSync(file, JSON.stringify({ run: TS, users: USERS, ids: { E: S.E, M: S.M, A: S.A, M2: S.M2, A2: S.A2, P: S.P, T: S.T }, lines: m.lines, hops: ui.hops, uiErrors: ui.errors, pageErrors: ui.pageErrors, consoleErrors: ui.consoleErrors }, null, 2)); + m.print(`run ${TS} (bob=${USERS.bob}, alice=${USERS.alice}, carol=${USERS.carol}) — JSON: ${file}`); + process.exit(m.hardFailures.length === 0 ? 0 : 1); +})().catch(err => { + m.print(`ABORTED: ${String(err).slice(0, 300)}`); + process.exit(1); +});