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
@@ -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;
});