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

This commit is contained in:
Sylvain Duchesne
2026-10-12 14:02:36 +02:00
parent a61c8704c9
commit 706ee132da
5 changed files with 355 additions and 27 deletions
@@ -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).
+1
View File
@@ -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",
+186
View File
@@ -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<string> =>
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
const snap = (page: Page, name: string): Promise<void> =>
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<boolean> {
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<boolean> {
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<boolean> {
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);
});
@@ -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
+155 -12
View File
@@ -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<string, unknown>): Promise<string> {
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<unknown[]> {
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<string, unknown>)[field] !== undefined);
expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;
});