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