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:
@@ -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