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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user