Close the adversarial review round: auth hardening, data fidelity against the platform context, backend grants and docker coherence
This commit is contained in:
@@ -33,7 +33,7 @@
|
||||
* resolved — undefined for everyone else.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo, type ReactNode } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core';
|
||||
import type { FpEventData, FpUserData, FpMeetingPointData } from '../data/types';
|
||||
import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor';
|
||||
@@ -109,10 +109,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const webId = session?.webId;
|
||||
const token = session?.token;
|
||||
|
||||
// WHO AM I — the Solid identity the session signed in as (display + the
|
||||
// membership key; the attendees collection carries actor URIs). The app's own
|
||||
// entity id space (`currentUserId`) is the PROFILE document URI, resolved
|
||||
// from the actor document below — the two never mix.
|
||||
// WHO AM I — the Solid identity the session signed in as. On this backend the
|
||||
// ACTOR URI (`webId`) is the canonical user key: the attendees collection
|
||||
// carries actor URIs, so `currentUserId` is the actor URI too, and the profile
|
||||
// record fetched for display is stamped with it (its own document URI is not
|
||||
// the identity this app compares memberships against). `currentPrincipal` is
|
||||
// display/log attribution only.
|
||||
const currentPrincipal = useCurrentPrincipal();
|
||||
|
||||
// --- READS over the shared as/Event container ------------------------------
|
||||
@@ -187,8 +189,13 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
});
|
||||
const myProfile: FpUserData | undefined = useMemo(() => {
|
||||
const record: unknown = myProfileQuery.result;
|
||||
return record !== undefined ? bindings.profile.toDomain(record) : undefined;
|
||||
}, [myProfileQuery.result, bindings]);
|
||||
if (record === undefined) return undefined;
|
||||
const domain = bindings.profile.toDomain(record);
|
||||
// Stamp the ACTOR URI as the id: it is the key memberships and every other
|
||||
// user reference live in. The profile's own document URI (which the binding
|
||||
// resolves) is deliberately not the id the app compares against.
|
||||
return webId ? { ...domain, id: webId } : domain;
|
||||
}, [myProfileQuery.result, bindings, webId]);
|
||||
const currentUser = myProfile;
|
||||
const currentUserId = myProfile?.id ?? '';
|
||||
|
||||
@@ -216,11 +223,25 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
if (record === undefined) {
|
||||
refused.push(id);
|
||||
} else {
|
||||
fresh.push(bindings.profile.toDomain(record));
|
||||
// Stamp the ACTOR URI the record was fetched for — the same key an
|
||||
// attendee reference carries — so `resolveParticipantUser` can match
|
||||
// it. The profile document URI differs and would never match.
|
||||
fresh.push({ ...bindings.profile.toDomain(record), id });
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (fresh.length) setResolvedProfiles(prev => [...prev, ...fresh]);
|
||||
if (fresh.length) {
|
||||
setResolvedProfiles(prev => [...prev, ...fresh]);
|
||||
// A successful fetch is a sign that access may have been granted since
|
||||
// an earlier refusal: drop the refusal marks for the attendees still in
|
||||
// scope, so a later pass retries them — a refusal is not permanent for
|
||||
// the page's life.
|
||||
setUnresolved(prev => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set([...prev].filter(id => !attendeeIds.includes(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}
|
||||
if (refused.length) setUnresolved(prev => new Set([...prev, ...refused]));
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
@@ -315,6 +336,24 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const { mutateAsync: updateRecord } = useUpdate();
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
// A join/leave is maintained asynchronously by the backend, so the immediate
|
||||
// invalidate can re-fetch BEFORE the collection is updated — and nothing
|
||||
// re-reads afterwards (the list subscription only reports membership changes).
|
||||
// ONE delayed re-invalidation per action closes that gap: no polling, at most
|
||||
// one pending timer, cancelled on unmount.
|
||||
const revalidateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
|
||||
revalidateTimer.current = null;
|
||||
}, []);
|
||||
const scheduleListRevalidate = useCallback(() => {
|
||||
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
|
||||
revalidateTimer.current = setTimeout(() => {
|
||||
revalidateTimer.current = null;
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, 1500);
|
||||
}, [invalidate, bindings]);
|
||||
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log(`${logPrefix} createEvent (refine):`, event.title);
|
||||
const response = await createRecord({
|
||||
@@ -381,7 +420,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
await postJoinActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
|
||||
setOverlay(prev => new Map(prev).set(mp.id, 'join'));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings]);
|
||||
scheduleListRevalidate();
|
||||
}, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings, scheduleListRevalidate]);
|
||||
|
||||
const leaveMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => {
|
||||
if (userId && userId !== webId) {
|
||||
@@ -403,7 +443,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
|
||||
setOverlay(prev => new Map(prev).set(mp.id, 'leave'));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings]);
|
||||
scheduleListRevalidate();
|
||||
}, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings, scheduleListRevalidate]);
|
||||
|
||||
// --- The rest of the contract -------------------------------------------------------
|
||||
const addFriend = useCallback((friendId: string) => {
|
||||
|
||||
+162
-24
File diff suppressed because one or more lines are too long
@@ -72,17 +72,30 @@ test('postJoinActivity discovers the outbox from the actor document and posts a
|
||||
}
|
||||
});
|
||||
|
||||
test('postLeaveActivity posts a Leave, and a missing host means no to field', async () => {
|
||||
test('postLeaveActivity posts a Leave addressed to the host', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
|
||||
return jsonResponse({}, 201);
|
||||
});
|
||||
try {
|
||||
await postLeaveActivity(input(undefined));
|
||||
await postLeaveActivity(input('http://localhost:3000/bob'));
|
||||
const postCall = double.calls[1]!;
|
||||
const body = JSON.parse(String(postCall.init.body));
|
||||
expect(body.type).toBe('Leave');
|
||||
expect(body).not.toHaveProperty('to');
|
||||
expect(body.to).toBe('http://localhost:3000/bob');
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// A Join/Leave with no addressee is silently dropped by the backend, so posting
|
||||
// one is refused loudly BEFORE any network call is made.
|
||||
test('a missing host refuses the post before any network call', async () => {
|
||||
const double = installFetch(() => jsonResponse({ id: ACTOR, outbox: OUTBOX }));
|
||||
try {
|
||||
await expect(postJoinActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
|
||||
await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
|
||||
expect(double.calls).toHaveLength(0);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
@@ -91,7 +104,7 @@ test('postLeaveActivity posts a Leave, and a missing host means no to field', as
|
||||
test('an actor document without an outbox refuses the post, naming the activity', async () => {
|
||||
const double = installFetch(() => jsonResponse({ id: ACTOR }));
|
||||
try {
|
||||
await expect(postJoinActivity(input(undefined))).rejects.toThrow(/carries no outbox.*Join/s);
|
||||
await expect(postJoinActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/carries no outbox.*Join/s);
|
||||
expect(double.calls).toHaveLength(1);
|
||||
} finally {
|
||||
double.restore();
|
||||
@@ -104,7 +117,7 @@ test('a non-2xx outbox response throws with the status', async () => {
|
||||
return jsonResponse({ error: 'Forbidden' }, 403);
|
||||
});
|
||||
try {
|
||||
await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/HTTP 403/);
|
||||
await expect(postLeaveActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/HTTP 403/);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
* (`outbox` predicate) at post time — the reference integration resolves it
|
||||
* the same way, and posting before the actor document has been read is what it
|
||||
* guards against. The activity's `object` is the meeting point's URI and its
|
||||
* `to` is the meeting point's host, so the host's Pod receives it.
|
||||
* `to` is the meeting point's host (the record's Pod-stamped `dc:creator`), so
|
||||
* the host's Pod receives it. THE HOST IS REQUIRED: a Join/Leave with no
|
||||
* addressee is silently dropped by the backend, so posting without a host URI
|
||||
* throws rather than constructing an activity nothing will ever receive.
|
||||
*
|
||||
* ASYNCHRONY IS THE PLATFORM'S. The collection update lands when the backend
|
||||
* has processed the activity — the caller must not treat the 2xx of the post
|
||||
@@ -32,7 +35,7 @@ const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||
export interface OutboxActivityInput {
|
||||
/** URI of the meeting point the activity targets. */
|
||||
meetingPointUri: string;
|
||||
/** The meeting point's host (as:organizer) — where the activity is addressed. */
|
||||
/** The meeting point's host (the record's `dc:creator`) — where the activity is addressed. */
|
||||
hostUri: string | undefined;
|
||||
/** The authenticated session — whose outbox receives the activity. */
|
||||
session: CurrentSession;
|
||||
@@ -40,6 +43,15 @@ export interface OutboxActivityInput {
|
||||
|
||||
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
|
||||
const { meetingPointUri, hostUri, session } = input;
|
||||
// A Join/Leave is addressed to the host: without a `to` the backend never
|
||||
// routes it, so the attendee would silently never be recorded. Fail loudly
|
||||
// before posting rather than construct an activity nothing receives.
|
||||
if (!hostUri) {
|
||||
throw new Error(
|
||||
`cannot post ${type} for ${meetingPointUri}: the meeting point's host is unknown ` +
|
||||
'(no addressee) — the activity would never be delivered.',
|
||||
);
|
||||
}
|
||||
const actor = await fetchActorDocument(session.webId, session.token);
|
||||
if (!actor.outbox) {
|
||||
throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
|
||||
@@ -49,8 +61,8 @@ async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput):
|
||||
type,
|
||||
actor: session.webId,
|
||||
object: meetingPointUri,
|
||||
to: hostUri,
|
||||
};
|
||||
if (hostUri) activity['to'] = hostUri;
|
||||
|
||||
const response = await fetch(actor.outbox, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -430,6 +430,62 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"sourceCode": "Then('le compte autoritatif de participation de A à l\\'événement {string} est {int}', { timeout: 60000 }, async function (this: FestipodWorld, _title: string, expected: number) {\n const freshFrame = (this as any).recoFreshFrame;\n const eventId = (this as any).isoEventId;\n await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });",
|
||||
"lineNumber": 165
|
||||
},
|
||||
{
|
||||
"pattern": "le pod provider local répond sur son endpoint OpenID",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le pod provider local répond sur son endpoint OpenID\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);\n expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);\n});",
|
||||
"lineNumber": 40
|
||||
},
|
||||
{
|
||||
"pattern": "le backend Festipod sert la description de son application",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le backend Festipod sert la description de son application\", async function (this: FestipodWorld) {\n const { backendUrl } = apodsStackConfig();\n const description = await readResource(undefined, `${backendUrl}/app`);\n // The description IS the OIDC client the app registers with: it must carry\n // the interop access-need group the Pod grants, not merely answer 200.\n expect(JSON.stringify(description.type)).to.contain('Application');\n expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');\n});",
|
||||
"lineNumber": 46
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est chargée depuis la pile locale",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When(\"l'application Festipod est chargée depuis la pile locale\", async function (this: FestipodWorld) {\n const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');\n expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;\n await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });",
|
||||
"lineNumber": 57
|
||||
},
|
||||
{
|
||||
"pattern": "l'entrée de connexion au Pod est proposée",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"l'entrée de connexion au Pod est proposée\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const host = new URL(podProviderUrl).host;\n await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });",
|
||||
"lineNumber": 65
|
||||
},
|
||||
{
|
||||
"pattern": "aucune erreur runtime n'a été émise au chargement",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"aucune erreur runtime n'a été émise au chargement\", function (this: FestipodWorld) {\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises au chargement de l'app :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
|
||||
"lineNumber": 71
|
||||
},
|
||||
{
|
||||
"pattern": "la session rejoint le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (data/outbox.ts): as:Join to the user's outbox, carrying\n // the meeting point record so the backend finds its apods:attendees collection.\n await postOutboxActivity(pod, 'Join', await readResource(pod, mpUri), pod.webId);\n});",
|
||||
"lineNumber": 158
|
||||
},
|
||||
{
|
||||
"pattern": "la session quitte le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n await postOutboxActivity(pod, 'Leave', await readResource(pod, mpUri), pod.webId);\n});",
|
||||
"lineNumber": 166
|
||||
},
|
||||
{
|
||||
"pattern": "la collection apods:attendees contient la session",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await readAttendees(pod, collectionUri);\n expect(members.map(linkId)).to.include(pod.webId);\n});",
|
||||
"lineNumber": 172
|
||||
},
|
||||
{
|
||||
"pattern": "le portefeuille contient des données de test",
|
||||
"keyword": "Given",
|
||||
|
||||
@@ -15,11 +15,11 @@ interface RawFeatureTestStatus {
|
||||
const rawResults: RawFeatureTestStatus[] = [
|
||||
{
|
||||
"featureId": "us-13",
|
||||
"totalScenarios": 10,
|
||||
"passed": 7,
|
||||
"totalScenarios": 7,
|
||||
"passed": 4,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au formulaire de relai d'événement",
|
||||
@@ -37,18 +37,6 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"name": "Pouvoir annuler le relai d'événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Détecter un événement similaire déjà relayé",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Importer un événement depuis une source externe",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Pas d'alerte doublon lors d'un import externe",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Modifier un événement",
|
||||
"status": "skipped"
|
||||
@@ -69,7 +57,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux détails d'un événement terminé",
|
||||
@@ -91,7 +79,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les commentaires existants",
|
||||
@@ -117,35 +105,23 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-7",
|
||||
"totalScenarios": 6,
|
||||
"passed": 2,
|
||||
"totalScenarios": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Consulter un événement avant inscription",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "S'inscrire à un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Se désinscrire d'un événement",
|
||||
"status": "skipped"
|
||||
"name": "Voir le bouton d'inscription sur l'écran",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Rechercher un événement existant",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données de l'écran",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Rechercher dans une base existante (Mobilizon)",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -155,7 +131,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 8,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Consulter un macro-événement",
|
||||
@@ -193,26 +169,22 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-16",
|
||||
"totalScenarios": 4,
|
||||
"passed": 4,
|
||||
"totalScenarios": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux points de rencontre",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le bouton pour proposer un point de rencontre",
|
||||
"name": "Voir le formulaire de proposition",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Ouvrir le formulaire de proposition",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Définir l'heure de rencontre",
|
||||
"name": "Renseigner les détails du point de rencontre",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
@@ -223,7 +195,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Partager un événement auquel je participe",
|
||||
@@ -253,7 +225,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Configurer les notifications de nouveaux participants",
|
||||
@@ -279,7 +251,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les événements à venir sur l'accueil",
|
||||
@@ -309,7 +281,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au profil d'un participant",
|
||||
@@ -343,7 +315,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la liste des événements depuis le profil",
|
||||
@@ -381,7 +353,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la liste des inscrits d'un événement",
|
||||
@@ -411,7 +383,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à mon profil",
|
||||
@@ -445,7 +417,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de profil",
|
||||
@@ -475,7 +447,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au partage de profil",
|
||||
@@ -505,7 +477,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au partage depuis le profil",
|
||||
@@ -535,7 +507,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de notification",
|
||||
@@ -557,7 +529,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de notification",
|
||||
@@ -575,11 +547,11 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-26",
|
||||
"totalScenarios": 4,
|
||||
"passed": 3,
|
||||
"totalScenarios": 3,
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au formulaire de relai d'événement",
|
||||
@@ -589,10 +561,6 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"name": "Définir le rayon d'intérêt",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Choisir une thématique",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les champs obligatoires",
|
||||
"status": "passed"
|
||||
@@ -605,7 +573,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 4,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au profil pour voir la photo",
|
||||
@@ -635,7 +603,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux détails d'un événement terminé",
|
||||
@@ -665,7 +633,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au bilan consolidé",
|
||||
@@ -687,7 +655,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 7,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la création d'atelier",
|
||||
@@ -725,7 +693,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la zone de notes personnelles",
|
||||
@@ -755,7 +723,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les commentaires existants d'un atelier",
|
||||
@@ -785,7 +753,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les ateliers d'un événement",
|
||||
|
||||
@@ -119,7 +119,10 @@ test('event toValues composes the datetimes and never stores the derived fields'
|
||||
content: 'Fabriquer des objets',
|
||||
startTime: '2026-02-08T14:00',
|
||||
endTime: '2026-02-08T17:00',
|
||||
location: 'La Maison du Vélo, Lyon',
|
||||
// `location` is `@type: @id` in the platform context: the display text is
|
||||
// written as an inline named node so it round-trips as `as:name`, never as a
|
||||
// text value resolved into a garbage IRI.
|
||||
location: { name: 'La Maison du Vélo, Lyon' },
|
||||
image: 'https://example.org/atelier.jpg',
|
||||
});
|
||||
});
|
||||
@@ -136,10 +139,10 @@ test('event toValues keeps a date-only end and drops a time-only start', () => {
|
||||
location: '',
|
||||
participantCount: 0,
|
||||
}),
|
||||
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: '' });
|
||||
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: { name: '' } });
|
||||
expect(
|
||||
event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }),
|
||||
).toEqual({ name: 'T', content: '', location: '' });
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' } });
|
||||
});
|
||||
|
||||
test('event toPatch sends only what changed', () => {
|
||||
@@ -147,7 +150,7 @@ test('event toPatch sends only what changed', () => {
|
||||
expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' });
|
||||
});
|
||||
|
||||
test('meetingPoint toDomain reads the context link in both its forms', () => {
|
||||
test('meetingPoint toDomain reads the context link and the host from dc:creator', () => {
|
||||
const { meetingPoint } = getBackend('activitypods').bindings;
|
||||
const objectForm = meetingPoint.toDomain({
|
||||
id: 'http://localhost:3000/alice/data/mp-1',
|
||||
@@ -155,7 +158,8 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
|
||||
name: 'Rdv entrée',
|
||||
content: 'On se retrouve là-bas',
|
||||
'as:context': { id: 'http://localhost:3000/alice/data/event-1' },
|
||||
organizer: 'http://localhost:3000/alice',
|
||||
// The Pod's DocumentTaggerMixin stamps this on create — the host convention.
|
||||
'dc:creator': 'http://localhost:3000/alice',
|
||||
location: { name: 'Café de la Place' },
|
||||
});
|
||||
expect(objectForm).toEqual({
|
||||
@@ -167,6 +171,14 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
|
||||
location: 'Café de la Place',
|
||||
time: '',
|
||||
});
|
||||
// The full IRI form (a context that does not compact `dc`) and the legacy
|
||||
// `organizer` fallback both still resolve the host.
|
||||
expect(
|
||||
meetingPoint.toDomain({
|
||||
id: 'mp-full-iri',
|
||||
'dc:creator': { '@id': 'http://localhost:3000/alice' },
|
||||
}).hostId,
|
||||
).toBe('http://localhost:3000/alice');
|
||||
const stringForm = meetingPoint.toDomain({
|
||||
id: 'http://localhost:3000/alice/data/mp-2',
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
@@ -178,7 +190,7 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
|
||||
expect(stringForm.title).toBeUndefined();
|
||||
});
|
||||
|
||||
test('meetingPoint toValues writes the context and organizer, not the unmapped fields', () => {
|
||||
test('meetingPoint toValues writes the context and the named location, never organizer', () => {
|
||||
const { meetingPoint } = getBackend('activitypods').bindings;
|
||||
const values = meetingPoint.toValues({
|
||||
eventId: 'http://localhost:3000/alice/data/event-1',
|
||||
@@ -192,10 +204,12 @@ test('meetingPoint toValues writes the context and organizer, not the unmapped f
|
||||
expect(values).toEqual({
|
||||
name: 'Rdv entrée',
|
||||
content: 'Sous l’horloge',
|
||||
location: 'Café de la Place',
|
||||
organizer: 'http://localhost:3000/alice',
|
||||
location: { name: 'Café de la Place' },
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
});
|
||||
// The host is the Pod-stamped creator; nothing here writes an `organizer` term
|
||||
// the platform context does not define (it would not round-trip).
|
||||
expect(values).not.toHaveProperty('organizer');
|
||||
});
|
||||
|
||||
test('profile toDomain maps the AS2 profile fields and derives the initials', () => {
|
||||
@@ -240,7 +254,8 @@ test('profile toValues writes the mapped fields and none of the derived ones', (
|
||||
name: 'Marie Dupont',
|
||||
preferredUsername: 'mariedupont',
|
||||
summary: 'Bio',
|
||||
location: 'Lyon',
|
||||
// `location` is `@type: @id`: the city is a named node, not a bare text value.
|
||||
location: { name: 'Lyon' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -67,8 +67,11 @@
|
||||
* together, and the data/live providers take the auth provider as a parameter. Its
|
||||
* `appStatusCheckInterval` is disabled deliberately: construction installs a
|
||||
* `setInterval` and a `visibilitychange` listener with no teardown (usage
|
||||
* declaration, friction 1), and Festipod drives `getAppStatus()` on its own
|
||||
* schedule. The `clientId` names the app; the app description document it points to
|
||||
* declaration, friction 1). Driving `getAppStatus()` on Festipod's own schedule is
|
||||
* the planned replacement, but it is NOT wired yet: the contract documents the
|
||||
* method's name only, not the shape of the `AppStatus` result, so the follow-up is
|
||||
* parked until that surface is pinned (handoff, follow-ups). The `clientId` names
|
||||
* the app; the app description document it points to
|
||||
* is served by Festipod's own backend, and the values resolve from the runtime
|
||||
* config — the SAME source the AuthGate flow uses, so both halves agree.
|
||||
*/
|
||||
@@ -134,6 +137,17 @@ function namedValue(value: unknown): string | undefined {
|
||||
return o ? asString(o['name']) ?? asString(o['url']) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A display string written into a predicate the platform context types as `@id`
|
||||
* (`location` → `as:location` with `"@type": "@id"`). A bare text value is
|
||||
* resolved against the base IRI into a garbage IRI on expansion, so the text is
|
||||
* wrapped in an inline named node — expanded to a blank node carrying `as:name`,
|
||||
* which IS defined in the context — and `namedValue` reads it back.
|
||||
*/
|
||||
function namedTextValue(text: string): Record<string, unknown> {
|
||||
return { name: text };
|
||||
}
|
||||
|
||||
/** A media value: a bare URL string, or an object such as an `as:Link` carrying url. */
|
||||
function urlValue(value: unknown): string | undefined {
|
||||
const direct = asString(value);
|
||||
@@ -200,7 +214,7 @@ function eventValues(data: Partial<FpEventData>): Record<string, unknown> {
|
||||
if (startTime !== undefined) out['startTime'] = startTime;
|
||||
const endTime = composeDateTime(data.endDate, data.endTime);
|
||||
if (endTime !== undefined) out['endTime'] = endTime;
|
||||
if (data.location !== undefined) out['location'] = data.location;
|
||||
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
|
||||
if (data.coverImage !== undefined) out['image'] = data.coverImage;
|
||||
return out;
|
||||
}
|
||||
@@ -229,7 +243,12 @@ function eventFromRecord(record: unknown): FpEventData {
|
||||
|
||||
/**
|
||||
* The meeting point field mapping. A meeting point is an `as/Event` plus `as:context`
|
||||
* pointing at the anchor event; `as:organizer` carries the host.
|
||||
* pointing at the anchor event. THE HOST IS NOT A STORED PREDICATE: the Pod's
|
||||
* DocumentTaggerMixin stamps `dc:creator` on every created resource, and the
|
||||
* creator IS the host (the welcometomyplace convention, also what the on-behalf
|
||||
* backend reads) — so the host is read back from `dc:creator`, never written.
|
||||
* `organizer` is not a term of the platform context, so writing it would not
|
||||
* round-trip and the host would be lost on read.
|
||||
* NOT mapped, on purpose: `time` (a display string such as '30 min avant', which is
|
||||
* not an AS2 datetime — unmapped until the creation flow produces one), `place` (AS2
|
||||
* has a single location slot, taken by `location`) and `inbox` (nothing provisions an
|
||||
@@ -239,12 +258,19 @@ function meetingPointValues(data: Partial<FpMeetingPointData>): Record<string, u
|
||||
const out: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) out['name'] = data.title;
|
||||
if (data.description !== undefined) out['content'] = data.description;
|
||||
if (data.location !== undefined) out['location'] = data.location;
|
||||
if (data.hostId !== undefined) out['organizer'] = data.hostId;
|
||||
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
|
||||
if (data.eventId !== undefined) out['as:context'] = data.eventId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The Pod-stamped creator of a record, tolerant of the term's compact and full forms. */
|
||||
function creatorId(record: JsonRecord | undefined): string | undefined {
|
||||
return (
|
||||
linkId(firstEntry(record?.['dc:creator'])) ??
|
||||
linkId(firstEntry(record?.['http://purl.org/dc/terms/creator']))
|
||||
);
|
||||
}
|
||||
|
||||
function meetingPointFromRecord(record: unknown): FpMeetingPointData {
|
||||
const r = asRecord(record);
|
||||
return {
|
||||
@@ -252,7 +278,9 @@ function meetingPointFromRecord(record: unknown): FpMeetingPointData {
|
||||
// A meeting point IS an `as/Event` with a context; one without it is an event,
|
||||
// and the reader tells the two apart by this very field.
|
||||
eventId: linkId(r?.['as:context']) ?? '',
|
||||
hostId: linkId(r?.['organizer']),
|
||||
// The host is the Pod-stamped creator; the `organizer` fallback is kept for
|
||||
// robustness against records written before the convention was adopted.
|
||||
hostId: creatorId(r) ?? linkId(r?.['organizer']),
|
||||
title: firstString(r?.['name']),
|
||||
description: firstString(r?.['content']),
|
||||
location: namedValue(r?.['location']) ?? '',
|
||||
@@ -271,7 +299,7 @@ function profileValues(data: Partial<FpUserData>): Record<string, unknown> {
|
||||
if (data.name !== undefined) out['name'] = data.name;
|
||||
if (data.username !== undefined) out['preferredUsername'] = data.username;
|
||||
if (data.bio !== undefined) out['summary'] = data.bio;
|
||||
if (data.city !== undefined) out['location'] = data.city;
|
||||
if (data.city !== undefined) out['location'] = namedTextValue(data.city);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ export interface ResolvedAuthConfig {
|
||||
*/
|
||||
export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
|
||||
const backendClientId = runtimeConfig.backendClientId?.trim();
|
||||
const backendUrl = runtimeConfig.backendUrl?.trim();
|
||||
// Strip trailing slashes first: appending `/app` to `http://host/` would double
|
||||
// the slash (`http://host//app`), and the client id must be the exact URI the
|
||||
// backend serves.
|
||||
const backendUrl = runtimeConfig.backendUrl?.trim().replace(/\/+$/, '');
|
||||
const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
|
||||
return {
|
||||
clientId,
|
||||
|
||||
Reference in New Issue
Block a user