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.
This commit is contained in:
Sylvain Duchesne
2026-10-13 14:49:39 +02:00
parent 6bcf40306b
commit d38f8a7384
3 changed files with 1314 additions and 0 deletions
+1
View File
@@ -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",
+499
View File
@@ -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<CheckOutcome>,
opts: { finding?: string } = {},
): Promise<boolean> {
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<CheckOutcome & { notice?: string }>,
): Promise<void> {
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<T>(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<HttpResult> {
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<void> => 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<T>(probe: () => Promise<T>, done: (v: T) => boolean, ms: number, every = 1000): Promise<T> {
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<ApodsSession> {
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<string | undefined> {
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<string> =>
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<string>;
/** 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<string, string>;
}
/**
* 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<string, number>();
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<string | undefined> {
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<LoginOutcome> {
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<LoginOutcome> => {
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<boolean> =>
page.url().startsWith(`${ctx.podFront}/authorize`)
&& (await accept.first().isVisible().catch(() => false))
&& (await accept.first().isEnabled().catch(() => false));
const shellReady = async (): Promise<boolean> =>
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<CheckOutcome> {
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<boolean> {
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);
}
+814
View File
@@ -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<string, string>;
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<number | undefined> =>
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<string, string[]>; sum: number }> {
const E = need(S.E, 'E');
const mps = (await catalogIds()).items.filter(i => ctxLink(i) === E);
const per = new Map<string, string[]>();
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}> <https://www.w3.org/ns/activitystreams#name> "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<boolean> {
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<string> {
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<boolean> {
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<boolean> {
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<CheckOutcome> =>
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<string | undefined> {
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<string | undefined> {
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<void> {
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<void> {
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<void> {
const G = 'writes (negative)';
const bobRead = async (uri: string): Promise<string> => {
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<string, string> = {};
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<void> {
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<void> {
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<void> {
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<CheckOutcome> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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}> <http://festipod.org/ns/test#anonymousPatchTripwire> "${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<void> {
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<string, number>();
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);
});