9381c206df
e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
992 lines
63 KiB
TypeScript
992 lines
63 KiB
TypeScript
/**
|
||
* 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).
|
||
*
|
||
* BACKEND PUBLIC SURFACE (T05.i allow-list). Anonymous GETs only, status codes
|
||
* only — never a body (before 88f23b7, `/key` served the app's private key):
|
||
* every allow-listed endpoint answers 200 (the control), a fixed list of
|
||
* non-public paths answers 404, the gate's refusal for every caller.
|
||
*
|
||
* VISIBILITY FOLLOWS THE OWNER (T05.i). An event V with a meeting point VM and
|
||
* a participant (Carol joins through her own outbox, as the app does) is
|
||
* switched private → public → private by Bob PATCHing `to as:Public` on his
|
||
* own two resources — a legitimate owner update (no UI for it yet). At every
|
||
* state: anonymous reads of V, VM and both attendees collections, the catalog
|
||
* listing, and Carol's membership (only the public read moves). For a
|
||
* collection the pod's refusal is 404 (VERIFIED 2026-10-13); it counts as one
|
||
* only next to Bob's 200 on the same URI at the same moment.
|
||
*
|
||
* 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,
|
||
postOutboxActivity,
|
||
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 TITLE_V = `Visibilite matrice ${TS}`;
|
||
const TITLE_VM = `Rdv visibilite ${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;
|
||
/** The visibility group's event, its meeting point, and their attendees collections. */
|
||
V?: string; VM?: string; AV?: string; AVM?: string;
|
||
/** Set only by the line that SAW Carol's WebID land in VM's collection. */
|
||
carolInVM?: boolean;
|
||
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` };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The backend's public surface: the allow-list of `backend/services/public-surface.js`
|
||
* (T05.i). Anonymous GETs, STATUS CODES ONLY — no body is ever printed or kept.
|
||
*/
|
||
async function publicSurface(): Promise<void> {
|
||
const G = 'backend public surface (anonymous GET, status codes only)';
|
||
const status = async (path: string): Promise<number> => (await http('GET', `${BACKEND}${path}`)).status;
|
||
const pathOf = (uri: string | undefined, what: string): string => {
|
||
const u = need(uri, what);
|
||
if (!u.startsWith(`${BACKEND}/`)) throw new Error(`${what} is not on the backend (${u})`);
|
||
return u.slice(BACKEND.length);
|
||
};
|
||
const shown = (path: string): string => path.replace(/\/[A-Za-z0-9_-]{16,}$/, '/<id>');
|
||
await m.hard('surface.public-200', G,
|
||
'CONTROL: every allow-listed public endpoint answers an anonymous GET',
|
||
'200 each: /app, /as/actor/app, /catalog, /public-key/<id>, /interop/access-need-group/<id>, /interop/access-need/<id>, /.well-known/context.jsonld, /.well-known/nodeinfo, /nodeinfo/2.1',
|
||
async () => {
|
||
const actor = parseJson((await http('GET', `${BACKEND}/as/actor/app`)).text);
|
||
const app = parseJson((await http('GET', `${BACKEND}/app`)).text);
|
||
const groupPath = pathOf(linkId(app?.['interop:hasAccessNeedGroup']), 'the access-need group (GET /app)');
|
||
const group = parseJson((await http('GET', `${BACKEND}${groupPath}`)).text);
|
||
const paths = [
|
||
'/app', '/as/actor/app', '/catalog',
|
||
pathOf(linkId(actor?.['publicKey']), 'the app actor\'s public key (GET /as/actor/app)'),
|
||
groupPath,
|
||
pathOf(linkId(arrayOf(group?.['interop:hasAccessNeed'])[0]), 'an access need (the access-need group)'),
|
||
'/.well-known/context.jsonld', '/.well-known/nodeinfo', '/nodeinfo/2.1',
|
||
];
|
||
const codes = await Promise.all(paths.map(status));
|
||
return { ok: codes.every(c => c === 200), actual: paths.map((p, i) => `${shown(p)} ${codes[i]}`).join(', ') };
|
||
});
|
||
const webfinger = `/.well-known/webfinger?resource=${encodeURIComponent(`acct:app@${new URL(BACKEND).host}`)}`;
|
||
const refusedPaths = [
|
||
'/', '/key', '/sparql', '/sparql?query=ASK%7B%7D', '/settings', '/proxy', '/_acl', '/.webhooks',
|
||
'/interop/application-registration', '/interop/access-grant', '/interop/data-grant',
|
||
'/as/actor/app/outbox', '/as/actor/app/followers', webfinger,
|
||
];
|
||
await m.hard('surface.non-public-404', G,
|
||
'anonymous GET on non-public backend paths (private keys, SPARQL, listings, registrations and grants, the app actor\'s collections, webfinger)',
|
||
'404 each — the gate\'s refusal for every caller (control: surface.public-200 on the same host)', async () => {
|
||
const codes = await Promise.all(refusedPaths.map(status));
|
||
return { ok: codes.every(c => c === 404), actual: refusedPaths.map((p, i) => `${p} ${codes[i]}`).join(', ') };
|
||
});
|
||
m.manual('manual.surface-writes', G,
|
||
'writes on the backend outside its two delivery endpoints are refused (404), and a crafted delivery to POST /as/actor/app/inbox or /.webhooks/<id> is never trusted',
|
||
'NOT exercised live (read-only sweeps only, user decision 2026-10-13). Covered by backend unit tests — '
|
||
+ 'public-surface.test.js (every write outside the two delivery endpoints refused; the LDP catch-all behind the gate) and '
|
||
+ 'emitted-activity.test.js (activity and object re-read from the pod before any decision).');
|
||
}
|
||
|
||
/** Bob's view of a resource's attendees collection link, polled (the backend attaches it asynchronously). */
|
||
async function collectionOf(uri: string): Promise<string | undefined> {
|
||
const bob = need(S.bob, 'Bob account');
|
||
return poll(async () => attendeesCollectionUri(parseJson((await http('GET', uri, { token: bob.token })).text) ?? {}), v => !!v, 30000);
|
||
}
|
||
|
||
/** Bob POSTs an event the way Festipod creates one (container POST, `to: as:Public`). */
|
||
async function postPublicEvent(name: string, extra: Record<string, unknown> = {}): Promise<{ uri?: string; status: number }> {
|
||
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, startTime: '2026-12-22T18:00:00Z', endTime: '2026-12-22T22:00:00Z', to: AS_PUBLIC, ...extra }),
|
||
});
|
||
return { uri: r.headers.get('Location') ?? undefined, status: r.status };
|
||
}
|
||
|
||
async function visibility(): Promise<void> {
|
||
const G = 'visibility follows the owner (V + VM + participant)';
|
||
await m.hard('visibility.setup-V', G, `Bob creates event V "${TITLE_V}" and its meeting point VM "${TITLE_VM}" as Festipod does (container POST, \`to: as:Public\`)`, '201 + Location for each', async () => {
|
||
const v = await postPublicEvent(TITLE_V);
|
||
S.V = v.uri;
|
||
const vm = S.V ? await postPublicEvent(TITLE_VM, { 'as:context': S.V }) : { status: 0, uri: undefined };
|
||
S.VM = vm.uri;
|
||
return { ok: !!S.V && !!S.VM, actual: `V: HTTP ${v.status} → ${S.V ?? '(no Location)'}; VM: HTTP ${vm.status} → ${S.VM ?? '(no Location)'}` };
|
||
});
|
||
await m.hard('visibility.setup-collections', G, 'V and VM each carry their apods:attendees collection', 'two distinct collection URIs within 30 s', async () => {
|
||
S.AV = await collectionOf(need(S.V, 'event V'));
|
||
S.AVM = await collectionOf(need(S.VM, 'meeting point VM'));
|
||
return { ok: !!S.AV && !!S.AVM && S.AV !== S.AVM, actual: `V: ${S.AV ?? '(none)'}; VM: ${S.AVM ?? '(none)'}` };
|
||
});
|
||
await m.hard('visibility.setup-join', G, "Carol joins VM through her own outbox (Join addressed to Bob, as the app does)", "Carol's WebID in VM's attendees items within 30 s (read by Bob)", async () => {
|
||
const carol = need(S.carol, 'Carol account');
|
||
const bob = need(S.bob, 'Bob account');
|
||
const AVM = need(S.AVM, "VM's attendees collection");
|
||
await postOutboxActivity(carol, 'Join', need(S.VM, 'meeting point VM'), bob.webId);
|
||
const a = await poll(() => attendees(AVM, bob.token), v => v.status === 200 && v.items.includes(carol.webId), 30000);
|
||
S.carolInVM = a.status === 200 && a.items.includes(carol.webId);
|
||
return { ok: S.carolInVM, actual: `HTTP ${a.status} items=${JSON.stringify(a.items)}` };
|
||
});
|
||
|
||
const AS_TO = 'https://www.w3.org/ns/activitystreams#to';
|
||
const setPublic = async (uri: string, on: boolean): Promise<number> =>
|
||
(await http('PATCH', uri, { token: need(S.bob, 'Bob account').token, contentType: 'application/sparql-update', body: `${on ? 'INSERT' : 'DELETE'} DATA { <${uri}> <${AS_TO}> <${AS_PUBLIC}> . }` })).status;
|
||
|
||
type Read = { label: string; uri: string; anon: number; bob?: number };
|
||
/** One observation of the four resources: anonymous status, plus Bob's for the collections (the 404 control). */
|
||
const observe = async (): Promise<Read[]> => {
|
||
const bob = need(S.bob, 'Bob account');
|
||
const targets: Array<[string, string, boolean]> = [
|
||
['V', need(S.V, 'event V'), false], ['VM', need(S.VM, 'meeting point VM'), false],
|
||
["VM's collection", need(S.AVM, "VM's attendees collection"), true], ["V's collection", need(S.AV, "V's attendees collection"), true],
|
||
];
|
||
return Promise.all(targets.map(async ([label, uri, isCollection]) => ({
|
||
label, uri,
|
||
anon: (await http('GET', uri)).status,
|
||
bob: isCollection ? (await http('GET', uri, { token: bob.token })).status : undefined,
|
||
})));
|
||
};
|
||
const readable = (r: Read): boolean => r.anon === 200;
|
||
/** A refusal: 401/403; for a collection also the pod's 404, but only while Bob reads the same URI 200. */
|
||
const closed = (r: Read): boolean => refused(r.anon, ANON) || (r.bob !== undefined && r.anon === 404 && r.bob === 200);
|
||
const show = (rs: Read[]): string => rs.map(r => `${r.label} anon ${r.anon}${r.bob !== undefined ? ` (Bob ${r.bob})` : ''}`).join(', ');
|
||
|
||
const states: Array<{ id: string; label: string; on: boolean; toggle: boolean }> = [
|
||
{ id: 'created-public', label: 'as created (public)', on: true, toggle: false },
|
||
{ id: 'private-1', label: 'switched private', on: false, toggle: true },
|
||
{ id: 'public-again', label: 'switched public again', on: true, toggle: true },
|
||
{ id: 'private-2', label: 'switched private again', on: false, toggle: true },
|
||
];
|
||
for (const st of states) {
|
||
if (st.toggle) {
|
||
await m.hard(`visibility.${st.id}-update`, G, `Bob ${st.on ? 'adds' : 'removes'} \`to as:Public\` on V and on VM (PATCH, his own token)`, '2xx for both', async () => {
|
||
const a = await setPublic(need(S.V, 'event V'), st.on);
|
||
const b = await setPublic(need(S.VM, 'meeting point VM'), st.on);
|
||
return { ok: a >= 200 && a < 300 && b >= 200 && b < 300, actual: `V PATCH ${a}, VM PATCH ${b}` };
|
||
});
|
||
}
|
||
await m.hard(`visibility.${st.id}-reads`, G,
|
||
`${st.label}: anonymous reads of V, VM, VM's collection, V's collection`,
|
||
st.on ? '200 for all four within 45 s' : 'V and VM refused 401/403; each collection refused (401/403, or the pod\'s 404 while Bob reads it 200) within 45 s',
|
||
async () => {
|
||
const want = st.on ? readable : closed;
|
||
const rs = await poll(observe, v => v.every(want), 45000, 1500);
|
||
return { ok: rs.every(want), actual: show(rs) };
|
||
});
|
||
await m.hard(`visibility.${st.id}-catalog`, G, `${st.label}: V and VM ${st.on ? 'listed in' : 'absent from'} GET /catalog`, `${st.on ? 'both listed' : 'neither listed'} within 45 s`, async () => {
|
||
const V = need(S.V, 'event V');
|
||
const VM = need(S.VM, 'meeting point VM');
|
||
const c = await poll(() => catalogIds(), v => v.ids.includes(V) === st.on && v.ids.includes(VM) === st.on, 45000, 1500);
|
||
return { ok: c.ids.includes(V) === st.on && c.ids.includes(VM) === st.on, actual: `V ${c.ids.includes(V) ? 'listed' : 'absent'}, VM ${c.ids.includes(VM) ? 'listed' : 'absent'}` };
|
||
});
|
||
await m.hard(`visibility.${st.id}-member`, G, `${st.label}: Carol is still a member of VM (only the public read moved)`, "Carol's WebID in VM's items (read by Bob, 200)", async () => {
|
||
need(S.carolInVM, 'the join on VM landed (visibility.setup-join)');
|
||
const carol = need(S.carol, 'Carol account');
|
||
const a = await attendees(need(S.AVM, "VM's attendees collection"), need(S.bob, 'Bob account').token);
|
||
return { ok: a.status === 200 && a.items.includes(carol.webId), actual: `HTTP ${a.status} items=${JSON.stringify(a.items)}` };
|
||
});
|
||
}
|
||
m.manual('manual.visibility-crafted', G,
|
||
'a webhook or activity CLAIMING a visibility change (not made on the pod) never grants, revokes, lists or de-lists',
|
||
'NOT exercised live (no forged activity, user decision 2026-10-13). Covered by backend unit tests — emitted-activity.test.js and '
|
||
+ 'public-read.service.test.js / catalog.service.test.js (decisions taken on the object re-read from the pod, under its per-object lock).');
|
||
}
|
||
|
||
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 publicSurface();
|
||
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();
|
||
await visibility();
|
||
} 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, V: S.V, VM: S.VM, AV: S.AV, AVM: S.AVM }, 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);
|
||
});
|