From 706ee132da464603ff389b20d1e462087676552b Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 12 Oct 2026 14:02:36 +0200 Subject: [PATCH] Bank the full login flow as an e2e gate and un-suspend join-to-collection: 5/5 smoke, programmatic app install for the test user --- .../handoff/refine-provider-abstraction.md | 2 +- package.json | 1 + scripts/e2e-login-flow.ts | 186 ++++++++++++++++++ .../event/features/e2e-activitypods.feature | 26 ++- .../event/steps/e2e/activitypods.steps.ts | 167 ++++++++++++++-- 5 files changed, 355 insertions(+), 27 deletions(-) create mode 100644 scripts/e2e-login-flow.ts diff --git a/.project/handoff/refine-provider-abstraction.md b/.project/handoff/refine-provider-abstraction.md index 98cf0e2..69ba8d8 100644 --- a/.project/handoff/refine-provider-abstraction.md +++ b/.project/handoff/refine-provider-abstraction.md @@ -123,7 +123,7 @@ The round: three adversarial passes (auth & security, data seam & backend, gates - `acl:Append` behaviour, as above. - The router's prefix stripping, with the second backend. - The four provider contributions. -- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. Known remainder: a revoked app is not detected client-side (getAppStatus wiring — now implementable, `{upgradeNeeded}` shape pinned) and the join→collection `@suspendu` scenarios are unblocked but not yet re-enabled. +- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. Known remainder: a revoked app is not detected client-side (getAppStatus wiring — now implementable, `{upgradeNeeded}` shape pinned). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side. - Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path. - Driving `getAppStatus()` is parked: the pinned contract documents the method but not the shape of the `AppStatus` result, so "stale" is not decidable without guessing. Belongs in the declaration's Frictions/Needs when the outward channel opens. - The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision). diff --git a/package.json b/package.json index 87547d7..4a08872 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract", "cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json", "test:auth-setup": "bun scripts/setup-test-auth.ts", + "test:e2e-flow": "bun scripts/e2e-login-flow.ts", "cucumber:report": "bun scripts/parse-test-results.ts", "features:parse": "bun scripts/parse-features.ts", "steps:extract": "bun scripts/extract-step-definitions.ts", diff --git a/scripts/e2e-login-flow.ts b/scripts/e2e-login-flow.ts new file mode 100644 index 0000000..3aea7cf --- /dev/null +++ b/scripts/e2e-login-flow.ts @@ -0,0 +1,186 @@ +/** + * Banked login-flow probe: drives the REAL local ActivityPods stack through the + * browser and asserts the whole app-install path — picker → pod login → consent → + * authenticated app → create event → create meeting point. + * + * WHY A SCRIPT, NOT A CUCUMBER STEP. The `@browser` smoke only checks the picker + * renders; the consent hop (an OIDC interaction on the pod frontend) and the + * app-registration round-trip are what actually proved the stack aligned. This + * runs it end-to-end and fails on any UNEXPECTED ≥400, so the regression is + * caught without a human driving the browser. + * + * ORIGIN. The OIDC redirect_uri is registered for `http://localhost:4000/login` + * only (the app's FRONT_URL), so the app MUST be served at APP_URL — do not + * serve a private instance on an ephemeral port: the pod rejects the redirect. + * + * The two benign ≥400 classes (the pod notification WebSocket channel, and an + * anonymous WAC profile read) are classified, never fatal. Anything else fails. + */ + +import { chromium, type Page } from 'playwright'; +import { podLogin, readResource, type ApodsStackConfig } from '../src/shared/support/activitypodsStack'; + +const strip = (u: string): string => u.replace(/\/+$/, ''); +const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003'); +const APP: string = strip(process.env.APP_URL || 'http://localhost:4000'); +const config: ApodsStackConfig = { + podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'), + backendUrl: BACKEND, +}; + +const USER = `flow${Date.now()}`; +const PASS = 'Test1test'; +const DEADLINE = Date.now() + 8 * 60 * 1000; + +interface HttpError { url: string; status: number; method: string; body: string } + +const redirects: string[] = []; +const errors4xx: HttpError[] = []; +const consoleErrors: string[] = []; +const hops: string[] = []; + +const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.'); +const bodyText = (page: Page): Promise => + page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim()); +const snap = (page: Page, name: string): Promise => + page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined); + +function wire(page: Page): void { + let last = ''; + page.on('framenavigated', f => { + if (f === page.mainFrame() && f.url() !== last) { + redirects.push(`${last || '(start)'} -> ${f.url()}`); + last = f.url(); + } + }); + page.on('response', r => { + if (r.status() >= 400) { + const method = r.request()?.method?.() ?? '?'; + r.text() + .then(b => errors4xx.push({ url: r.url(), status: r.status(), method, body: b.slice(0, 400) })) + .catch(() => errors4xx.push({ url: r.url(), status: r.status(), method, body: '(unreadable)' })); + } + }); + page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 300)); }); + page.on('pageerror', e => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 300))); +} + +/** Drive picker → pod login → consent until the app shell is reached. */ +async function driveLogin(page: Page): Promise { + let authorizeSeen = 0; + for (let i = 0; i < 20 && Date.now() < DEADLINE; i++) { + const url = page.url(); + if (hops[hops.length - 1] !== clean(url)) hops.push(clean(url)); + + if (url.includes(':5000') && (await page.locator('input[name="username"]').count().catch(() => 0))) { + hops.push(' [login] submitting credentials'); + await page.locator('input[name="username"]').fill(USER).catch(() => {}); + await page.locator('input[name="password"]').fill(PASS).catch(() => {}); + const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i }); + if (await btn.count()) await btn.first().click().catch(() => {}); + else await page.locator('button[type=submit]').first().click().catch(() => {}); + await page.waitForTimeout(3500); + continue; + } + + if (url.includes(':5000/authorize')) { + authorizeSeen++; + const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i }); + let visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false); + hops.push(` [authorize #${authorizeSeen}] accept visible=${visible}`); + await snap(page, 'login-flow-consent'); + if (visible) { await acc.first().click().catch(() => {}); hops.push(' [authorize] clicked accept'); } + if (authorizeSeen >= 3) { hops.push(' [LOOP] authorize revisited 3x — consent loop'); return false; } + await page.waitForTimeout(5000); + continue; + } + + if (url.startsWith(APP)) { + if (url.includes('/login')) { await page.waitForTimeout(5000); continue; } + const picker = await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0); + if (!picker) { hops.push(' [app] shell reached'); return true; } + } + await page.waitForTimeout(1500); + } + return false; +} + +async function createEvent(page: Page): Promise { + await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {}); + await page.waitForTimeout(1500); + await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill('Concert flow').catch(() => {}); + const dates = page.locator('input[type="date"]'); + if (await dates.count()) await dates.first().fill('2026-12-20').catch(() => {}); + await page.getByRole('button', { name: /Suivant/ }).click().catch(() => {}); + await page.waitForTimeout(1200); + const next = page.getByRole('button', { name: /Suivant/ }); + if (await next.count()) await next.click().catch(() => {}); + await page.waitForTimeout(1200); + await page.getByRole('button', { name: /Relayer l'événement/ }).click().catch(() => {}); + await page.waitForTimeout(5000); + const u = page.url(); + return /\/events\/.+/.test(u) && !u.includes('/events/new'); +} + +async function createMeetingPoint(page: Page): Promise { + const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i }); + if (!(await propose.count())) return false; + await propose.first().click().catch(() => {}); + await page.waitForTimeout(1500); + await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill('Rdv flow').catch(() => {}); + await page.getByRole('button', { name: /Créer le point de rencontre/ }).click().catch(() => {}); + await page.waitForTimeout(5000); + const txt = await bodyText(page); + return /POINTS DE RENCONTRE|Rdv flow/i.test(txt) && !/pas pu/i.test(txt); +} + +/** The two benign ≥400 classes; anything else is a real failure. */ +function classify(e: HttpError): string | null { + if (e.url.includes('/.notifications/WebSocketChannel2023')) return 'websocket-channel 403 (benign)'; + if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return 'WAC profile-read 403 (benign)'; + return null; +} + +(async () => { + // Sign in (signup fallback) via the shared helper — the UI login then uses the same creds. + await podLogin(config, USER, PASS); + + const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] }); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + wire(page); + + await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click(); + const authenticated = await driveLogin(page); + await snap(page, 'login-flow-after-login'); + const eventCreated = authenticated ? await createEvent(page) : false; + await snap(page, 'login-flow-after-event'); + const meetingPointCreated = eventCreated ? await createMeetingPoint(page) : false; + await snap(page, 'login-flow-after-meeting-point'); + + await ctx.close(); + await browser.close(); + await new Promise(r => setTimeout(r, 1200)); + + const fatal = errors4xx.filter(e => classify(e) === null); + const benign = errors4xx.map(e => ({ e, label: classify(e) })).filter(x => x.label).map(x => ({ status: x.e.status, url: x.e.url, label: x.label })); + const report = { + user: USER, + authenticated, + eventCreated, + meetingPointCreated, + redirects, + hops, + benign4xx: benign, + fatal4xx: fatal, + consoleErrors, + ok: authenticated && eventCreated && meetingPointCreated && fatal.length === 0, + }; + console.log(JSON.stringify(report, null, 2)); + process.exit(report.ok ? 0 : 1); +})().catch(err => { + console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), redirects, errors4xx, consoleErrors }, null, 2)); + process.exit(1); +}); diff --git a/src/modules/event/features/e2e-activitypods.feature b/src/modules/event/features/e2e-activitypods.feature index 58aae80..308c0fb 100644 --- a/src/modules/event/features/e2e-activitypods.feature +++ b/src/modules/event/features/e2e-activitypods.feature @@ -29,30 +29,28 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale Alors la lecture du point de rencontre expose son lien as:context vers l'événement Et l'événement et le point de rencontre partagent le conteneur as:Event - # SUSPENDU — le flux d'inscription exige que l'app Festipod soit INSTALLÉE sur le - # Pod (consentement de l'agent d'autorisation + dispatcher d'activités). Sans cette - # installation, l'activité `as:Join` postée sur l'outbox de l'utilisateur n'est pas - # délivrée au backend et la collection `apods:attendees` reste inchangée — vérifié - # sur la pile locale (POST outbox 201, collection inchangée). Le tag `@suspendu` - # l'exclut de la porte ; les steps restent écrits pour la reprise. - # Bloqueur de reprise : brancher l'installation de l'app (flux OIDC de T04.d) sur la - # pile locale, sinon délivrer l'activité à l'inbox de l'app. - @suspendu + # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un + # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape + # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur + # l'outbox de l'utilisateur EST délivrée au backend, qui tient la collection + # `apods:attendees` du point de rencontre. Le point de rencontre est créé dans le + # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée + # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas. Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test" + Et l'application Festipod est installée sur le Pod de la session Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees Quand la session rejoint le point de rencontre Alors la collection apods:attendees contient la session Quand la session quitte le point de rencontre Alors la collection apods:attendees ne contient plus la session - # SUSPENDU — dépend du scénario précédent : sans inscription sur le Pod, il n'y a ni - # compteur ni liste à lire. La règle de lecture qu'il vise (compteur = total de la - # collection, une référence non résolue restant comptée et rendue anonyme) est - # couverte au niveau unitaire par `src/shared/context/refineDerive.test.ts`. - @suspendu + # Dépend du scénario précédent pour la mise en place, mais lit la collection + # elle-même : le compteur est le total de la collection (jamais les profils + # résolus) et une référence non résolue reste comptée et rendue anonyme. Scénario: Le compteur et la liste des participants tiennent une référence non résolue Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test" + Et l'application Festipod est installée sur le Pod de la session Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees Quand la session rejoint le point de rencontre Alors le compteur du point de rencontre compte cette inscription diff --git a/src/modules/event/steps/e2e/activitypods.steps.ts b/src/modules/event/steps/e2e/activitypods.steps.ts index 386ebff..1754612 100644 --- a/src/modules/event/steps/e2e/activitypods.steps.ts +++ b/src/modules/event/steps/e2e/activitypods.steps.ts @@ -35,6 +35,61 @@ function requires(world: FestipodWorld, value: string | undefined, what: string) return value; } +/** The ActivityPods context the Pod round-trips the `as:`/`apods:` CURIEs against. */ +const JSON_CONTEXT = 'https://activitypods.org/context.json'; + +function arrayOf(value: unknown): unknown[] { + return value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]; +} + +/** + * Create a resource in ONE of the Pod's registered containers (the Event or + * Collection DataRegistration the app is granted on). The app can only WRITE + * where its access needs were mapped to WAC rights at install time — a resource + * created in the user's raw storage root is outside the grant, so the backend's + * join/leave collection update is refused (403). Placement IS part of the + * scenario, not an incidental detail of the helper. + */ +async function createInContainer(session: ApodsSession, containerUri: string, body: Record): Promise { + const response = await fetch(containerUri, { + method: 'POST', + headers: { + 'Content-Type': 'application/ld+json', + Accept: 'application/ld+json', + Authorization: `Bearer ${session.token}`, + }, + body: JSON.stringify({ '@context': JSON_CONTEXT, ...body }), + }); + const location = response.headers.get('Location') ?? response.headers.get('location'); + if (!response.ok || !location) { + throw new Error(`Create in ${containerUri} failed: HTTP ${response.status} ${(await response.text()).slice(0, 200)}`); + } + return location; +} + +/** + * Read a collection until `predicate` holds, or the timeout expires. The + * backend's collection update lands ASYNCHRONOUSLY (the platform's own model — + * see src/shared/data/outbox.ts), so a single read right after the Join POST + * races the update; polling is what makes the assertion about reality, not luck. + * Returns the last read on timeout so the caller's `expect` reports the truth. + */ +async function waitForMembers( + session: ApodsSession, + collectionUri: string, + predicate: (members: unknown[]) => boolean, + timeoutMs = 20000, +): Promise { + const end = Date.now() + timeoutMs; + let members: unknown[] = []; + while (Date.now() < end) { + members = await readAttendees(session, collectionUri); + if (predicate(members)) return members; + await new Promise(resolve => setTimeout(resolve, 500)); + } + return members; +} + // ── The stack is up ──────────────────────────────────────────────────────── Then("le pod provider local répond sur son endpoint OpenID", async function (this: FestipodWorld) { @@ -85,6 +140,47 @@ Given( }, ); +Given("l'application Festipod est installée sur le Pod de la session", async function (this: FestipodWorld) { + const pod = session(this); + const config = apodsStackConfig(); + // The registered agent IS the Solid-OIDC client_id: the app actor URI the + // backend serves as both the actor and its Client ID Document. Registering + // against `/app` (the alias) is refused — the agent must be the actor URI. + const description = await readResource(undefined, `${config.backendUrl}/app`); + const appUri = requires(this, recordUri(description), 'app actor URI (the id of GET /app)'); + const groupUri = requires(this, linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI'); + const group = await readResource(undefined, groupUri); + const acceptedAccessNeeds = arrayOf(group['interop:hasAccessNeed']); + const acceptedSpecialRights = arrayOf(group['apods:hasSpecialRights']); + + const response = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pod.token}` }, + body: JSON.stringify({ appUri, acceptedAccessNeeds, acceptedSpecialRights }), + }); + if (!response.ok) { + const text = await response.text(); + // Idempotent: a re-run finds the registration already in place. Any other + // refusal is a real failure, reported verbatim. + if (response.status === 400 && text.includes('already has an application registration')) return; + throw new Error(`App installation failed: HTTP ${response.status} ${text.slice(0, 300)}`); + } + + // Registration is confirmed asynchronously by the app backend (the Pod posts a + // CREATE to the user's outbox), and the registered shape-tree containers come + // with it. Wait for the Event DataRegistration before a scenario writes there. + const eventContainer = `${pod.storage}/as/event`; + const end = Date.now() + 20000; + while (Date.now() < end) { + const status = await fetch(eventContainer, { headers: { Authorization: `Bearer ${pod.token}` } }) + .then(r => r.status) + .catch(() => 0); + if (status === 200) return; + await new Promise(resolve => setTimeout(resolve, 500)); + } + throw new Error(`Registered container ${eventContainer} never appeared after install`); +}); + When( 'je crée un événement {string} et un point de rencontre {string} rattaché par as:context', async function (this: FestipodWorld, title: string, meetingPointTitle: string) { @@ -139,40 +235,57 @@ Given( 'un point de rencontre {string} rattaché à un événement, avec sa collection apods:attendees', async function (this: FestipodWorld, title: string) { const pod = session(this); - const anchor = await createResource(pod, { type: 'Event', name: `${title} — ancre` }); - const collection = await createResource(pod, { type: 'Collection' }); - const mp = await createResource(pod, { + // Created in the Event DataRegistration the app was granted on at install. + const anchor = await createInContainer(pod, `${pod.storage}/as/event`, { type: 'Event', name: `${title} — ancre` }); + // The `apods:attendees` collection is the APP BACKEND's to attach: when it + // processes the meeting point's creation, `pod-collections-handler.onCreate` + // creates and links it. Hand-attaching one (or creating the collection + // ourselves) is NOT the app's path and trips an uncaught TypeError in + // @activitypods/app — so we create the bare meeting point and let it attach. + const mp = await createInContainer(pod, `${pod.storage}/as/event`, { type: 'Event', name: title, 'as:context': anchor, - 'apods:attendees': collection, organizer: pod.webId, }); this.apods.anchorUri = anchor; - this.apods.collectionUri = collection; this.apods.meetingPointUri = mp; - expect(attendeesCollectionUri(await readResource(pod, mp))).to.equal(collection); + // The attach is ASYNCHRONOUS (the backend reacts to the Pod activity), so the + // collection URI is read back once the record carries it. + const end = Date.now() + 20000; + let collection: string | undefined; + while (Date.now() < end) { + collection = attendeesCollectionUri(await readResource(pod, mp)); + if (collection) break; + await new Promise(resolve => setTimeout(resolve, 500)); + } + if (!collection) throw new Error(`The meeting point ${mp} never gained an apods:attendees collection`); + this.apods.collectionUri = collection; }, ); When('la session rejoint le point de rencontre', async function (this: FestipodWorld) { const pod = session(this); const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI'); - // The app's own path (data/outbox.ts): as:Join to the user's outbox, carrying - // the meeting point record so the backend finds its apods:attendees collection. - await postOutboxActivity(pod, 'Join', await readResource(pod, mpUri), pod.webId); + // The app's own path (src/shared/data/outbox.ts): as:Join to the user's own + // outbox, `object` = the meeting point URI and `to` = the HOST (the record's + // dc:creator). Without the addressee the backend silently drops the activity; + // and an activity carrying no routing target never reaches the host. + const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId; + await postOutboxActivity(pod, 'Join', mpUri, host); }); When('la session quitte le point de rencontre', async function (this: FestipodWorld) { const pod = session(this); const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI'); - await postOutboxActivity(pod, 'Leave', await readResource(pod, mpUri), pod.webId); + const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId; + await postOutboxActivity(pod, 'Leave', mpUri, host); }); Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) { const pod = session(this); const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI'); - const members = await readAttendees(pod, collectionUri); + const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId)); expect(members.map(linkId)).to.include(pod.webId); }); @@ -181,7 +294,37 @@ Then( async function (this: FestipodWorld) { const pod = session(this); const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI'); - const members = await readAttendees(pod, collectionUri); + const members = await waitForMembers(pod, collectionUri, found => !found.map(linkId).includes(pod.webId)); expect(members.map(linkId)).to.not.include(pod.webId); }, ); + +// ── Read-side derivations over the live collection (the Alice/Bob rule) ───── + +Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) { + const pod = session(this); + const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI'); + const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId)); + const collection = await readResource(pod, collectionUri); + // The count is the collection total — kept as-is, never the number of + // RESOLVED profiles, so it stays total for a reader who resolves nobody. + const raw = collection.totalItems ?? collection['as:totalItems']; + const total = raw === undefined ? members.length : Number(raw); + expect(total, 'the count reflects the joined session').to.be.greaterThan(0); + expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId); +}); + +Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) { + const pod = session(this); + const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI'); + const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId)); + const joined = members.find(member => linkId(member) === pod.webId); + expect(joined, 'the joined session is listed').to.not.be.undefined; + // "Anonymous" is the read-side fact: the member travels as a REFERENCE with no + // profile the reader can resolve — the list has nothing but the URI to render, + // which is exactly what the app shows for a participant it cannot dereference. + const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname']; + const hasProfile = + typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record)[field] !== undefined); + expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false; +});