Files
festipod/backend/services/registration.service.test.js
T
Sylvain Duchesne 908f7ea973 Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work
Participation privacy (decision_2026-10-29_participation-privacy-mechanism):
a sign-up is a private Join naming the meeting point, addressed to nobody,
handled by the backend on emission. Attendee lists hold member URNs hashed
from (WebID, event, a secret kept in the participant's profile), so counts
stay public and only contacts recognise anyone; the creator has no read
right and is notified anonymously. Each person's participations collection,
created by the backend at the first sign-up, is readable by their /contacts
WAC group only, never world-readable. The member URN is sticky per
(meeting point, participant) and every collection write is verified by
reading back, so a refused write leaves the act unrecorded and retryable.
The EventRef mechanism and its browser-side provisioning are gone.

The backend now runs with WebACL on, as the reference apps do, on its own
secure datasets festipod / settings-festipod instead of the pod provider's
settings dataset; pod-notifications needs it. The compose header and the
deployment leaf say how to reset a stack whose datasets predate this.

The rights matrix covers the new mechanism live (148 hard lines), including
a forged contact Accept between throwaway accounts: the stranger enters the
contacts list (platform defect, reported upstream) but is refused the profile
and the participations. Tests may attack as long as they never break the dev
environment (rule_tests-never-break-the-dev-environment).

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
2026-10-30 17:06:38 +01:00

811 lines
39 KiB
JavaScript

const { test, expect, describe } = require('bun:test');
const registration = require('./registration.service');
const { memberUrn } = require('./member-urn');
const { lastAttendanceQuery, isNewerAct, attendanceSubject } = require('./attendance-ledger');
const { hasOwnPublicRead, ownReaders } = require('./public-read-acl');
const {
world,
activity,
meetingPoint,
profile,
profileOf,
groupOf,
contactsOf,
attendeesOf,
createdCollectionAcl,
APP,
ALICE,
BOB,
CAROL,
MALLORY,
EVENT1,
EVENT2,
SECRETS,
FP_SECRET,
FP_PARTICIPATIONS
} = require('./participation-world');
// Alice created three meeting points: two on the festival (EVENT1), one on the concert (EVENT2).
const MP1 = `${ALICE}/data/events/mp1`;
const MP2 = `${ALICE}/data/events/mp2`;
const MP3 = `${ALICE}/data/events/mp3`;
const MEETING_POINTS = { [MP1]: meetingPoint(MP1), [MP2]: meetingPoint(MP2), [MP3]: meetingPoint(MP3, { context: EVENT2 }) };
const EVENT_OF = { [MP1]: EVENT1, [MP2]: EVENT1, [MP3]: EVENT2 };
const T0 = '2026-10-29T10:00:00.000Z';
const T1 = '2026-10-29T10:00:05.000Z';
const T2 = '2026-10-29T10:00:10.000Z';
const join = (mp, published = T0, who = BOB, n = 1) => activity('Join', who, mp, published, n);
const leave = (mp, published = T1, who = BOB, n = 1) => activity('Leave', who, mp, published, n);
const urnOf = (who, mp = MP1) => memberUrn({ participantUri: who, eventUri: EVENT_OF[mp], secret: SECRETS[who] });
/** The world with Alice's meeting points and the given stored activities. */
function setup({ acts = [], stored = {}, ...options } = {}) {
return world({ stored: { ...MEETING_POINTS, ...Object.fromEntries(acts.map(a => [a.id, a])), ...stored }, ...options });
}
const emitJoin = (w, act, actorUri = act.actor) => w.emit('join', act, actorUri);
const emitLeave = (w, act, actorUri = act.actor) => w.emit('leave', act, actorUri);
const attendees = (w, mp = MP1) => w.state.items[attendeesOf(mp)] || [];
const participationsUri = (w, who = BOB) => {
const link = w.state.stored[profileOf(who)][FP_PARTICIPATIONS];
return link && link.id;
};
const participations = (w, who = BOB) => w.state.items[participationsUri(w, who)] || [];
const ledgerOf = (w, mp = MP1, who = BOB) => w.state.ledger[attendanceSubject(mp, who)];
const WRITES = [
'attendees.add',
'attendees.remove',
'pod-collections.add',
'pod-collections.remove',
'pod-collections.createAndAttach',
'pod-permissions.add',
'signature.proxy.query',
'triplestore.update',
'pod-notifications.send'
];
const writes = calls => calls.filter(c => WRITES.includes(c.action));
/** An existing participations collection of `who`, linked from their profile, with the given ACL. */
function existingCollection(who, acl) {
const uri = `${who}/data/as/collection/participations-existing`;
return {
uri,
stored: { [profileOf(who)]: profile(who, { [FP_PARTICIPATIONS]: { id: uri } }) },
acls: { [uri]: acl },
items: { [uri]: [] }
};
}
/** The ACL of a collection readable by the participant's `/contacts` WAC group, the app keeping its Control. */
const protectedAcl = (uri, group = groupOf(BOB)) => [
{ '@id': `http://localhost:3000/_acl${new URL(uri).pathname}#Read`, 'acl:mode': 'acl:Read', 'acl:agentGroup': group },
{ '@id': `http://localhost:3000/_acl${new URL(uri).pathname}#Control`, 'acl:mode': 'acl:Control', 'acl:agent': APP }
];
describe("registration — a Join emitted in the participant's outbox", () => {
test('the normal case: the member URN joins the list, the meeting point joins the participant\'s collection, the act is recorded', async () => {
const j = join(MP1);
const w = setup({ acts: [j] });
await emitJoin(w, j);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w)).not.toContain(BOB); // never the WebID
const add = w.calls.find(c => c.action === 'attendees.add');
expect(add.params).toEqual({ collectionUri: attendeesOf(MP1), itemUri: urnOf(BOB), actorUri: ALICE });
expect(w.state.created).toHaveLength(1);
expect(participations(w)).toEqual([MP1]);
const added = w.calls.find(c => c.action === 'pod-collections.add');
expect(added.params).toEqual({ collectionUri: participationsUri(w), itemUri: MP1, actorUri: BOB });
expect(ledgerOf(w)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: urnOf(BOB) });
expect(w.state.notifications).toHaveLength(1);
expect(w.logs.error).toEqual([]);
});
test('the webhook body is a hint: its actor and its embedded object are ignored', async () => {
const j = join(MP1);
const w = setup({ acts: [j] });
await emitJoin(w, { ...j, actor: MALLORY, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } }, BOB);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w, MP2)).toEqual([]);
expect(w.state.items[`${MALLORY}/data/c`]).toBeUndefined();
});
test('the act is handled on emission only: the creator\'s inbox has no handler', () => {
expect(registration.activities.join.onReceive).toBeUndefined();
expect(registration.activities.leave.onReceive).toBeUndefined();
expect(typeof registration.activities.join.onEmit).toBe('function');
expect(typeof registration.activities.leave.onEmit).toBe('function');
});
});
describe('registration — refusals write nothing and record nothing', () => {
const refused = async (w, act, { log = 'warn', contains } = {}) => {
await emitJoin(w, act);
expect(writes(w.calls)).toEqual([]);
if (contains) expect(w.logs[log].some(m => m.includes(contains))).toBe(true);
};
test('a profile without a participation secret', async () => {
const j = join(MP1);
await refused(setup({ acts: [j], stored: { [profileOf(BOB)]: profile(BOB, { [FP_SECRET]: undefined }) } }), j, { contains: 'no usable participation secret' });
});
test('a malformed secret (uppercase, short, non-hex, several values)', async () => {
for (const secret of [SECRETS[BOB].toUpperCase(), SECRETS[BOB].slice(2), 'z'.repeat(64), [SECRETS[BOB], SECRETS[CAROL]]]) {
const j = join(MP1);
await refused(setup({ acts: [j], stored: { [profileOf(BOB)]: profile(BOB, { [FP_SECRET]: secret }) } }), j, { contains: 'no usable participation secret' });
}
});
test('a stored object that is not an Event (a Note)', async () => {
const j = join(MP1);
await refused(setup({ acts: [j], stored: { [MP1]: { ...meetingPoint(MP1), type: 'Note' } } }), j, { contains: 'is not a Event' });
});
test('an Event with no as:context (no event to anchor the URN)', async () => {
const j = join(MP1);
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { context: undefined }) } }), j, { contains: 'as:context' });
});
test('a meeting point not in the pod of its dc:creator, or naming no creator', async () => {
const j = join(MP1);
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': MALLORY }) } }), j, { contains: 'not in the pod of its creator' });
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': undefined }) } }), j, { contains: 'dc:creator' });
});
test('an actor whose profile lies outside their pod', async () => {
const j = join(MP1);
const w = setup({ acts: [j], stored: { [BOB]: { id: BOB, type: 'Person', url: profileOf(CAROL), 'apods:contacts': `${BOB}/contacts` } } });
await refused(w, j, { contains: 'names no single profile in their pod' });
});
test('a profile linking a participations collection outside the participant\'s pod: ignored, the act refused', async () => {
const j = join(MP1);
const foreign = `${MALLORY}/data/as/collection/x`;
const w = setup({ acts: [j], stored: { [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: { id: foreign } }) } });
await refused(w, j, { log: 'error', contains: 'NOT recorded' });
expect(w.state.items[foreign]).toBeUndefined();
});
test('an act without a pod stamp cannot be ordered', async () => {
const j = { ...join(MP1), published: undefined };
await refused(setup({ acts: [j] }), j, { contains: 'no pod stamp' });
});
test('a Join the pod does not store (webhook only)', async () => {
const j = join(MP1);
await refused(setup(), j, { contains: 'ignored a Join' });
});
});
describe('registration — the member URN: one per person and event, computed by the backend', () => {
test('the same person on two meeting points of one event → the same URN; on another event → another', async () => {
const acts = [join(MP1), join(MP2), join(MP3)];
const w = setup({ acts });
for (const a of acts) await emitJoin(w, a);
expect(attendees(w, MP1)).toEqual([urnOf(BOB, MP1)]);
expect(attendees(w, MP2)).toEqual(attendees(w, MP1));
expect(attendees(w, MP3)).toEqual([urnOf(BOB, MP3)]);
expect(attendees(w, MP3)).not.toEqual(attendees(w, MP1));
expect(participations(w).sort()).toEqual([MP1, MP2, MP3].sort());
});
test('two people on one meeting point → two URNs, each derived from its own WebID and secret', async () => {
const acts = [join(MP1), join(MP1, T0, CAROL)];
const w = setup({ acts });
for (const a of acts) await emitJoin(w, a);
expect(attendees(w).sort()).toEqual([urnOf(BOB), urnOf(CAROL)].sort());
expect(urnOf(BOB)).not.toBe(urnOf(CAROL));
});
});
describe('registration — the participations collection', () => {
test('created ONCE under two concurrent Joins of one participant (two meeting points, two locks)', async () => {
const acts = [join(MP1), join(MP2)];
const w = setup({ acts, delays: { create: 30 } });
await Promise.all(acts.map(a => emitJoin(w, a)));
expect(w.state.created).toHaveLength(1);
expect(w.state.stored[profileOf(BOB)][FP_PARTICIPATIONS]).toEqual({ id: w.state.created[0] });
expect(participations(w).sort()).toEqual([MP1, MP2].sort());
});
test('on creation: the pod\'s public read is removed, then the /contacts group granted read — before the meeting point is added', async () => {
const j = join(MP1);
const w = setup({ acts: [j] });
await emitJoin(w, j);
const acl = w.state.acls[participationsUri(w)];
expect(hasOwnPublicRead(acl)).toBe(false);
expect(ownReaders(acl)).toEqual({ agent: [], agentClass: [], agentGroup: [groupOf(BOB)] });
const order = w.calls.map(c => c.action).filter(a => ['pod-permissions.add', 'signature.proxy.query', 'pod-collections.add'].includes(a));
expect(order).toEqual(['signature.proxy.query', 'pod-permissions.add', 'pod-collections.add']);
const grant = w.calls.find(c => c.action === 'pod-permissions.add');
expect(grant.params).toMatchObject({ agentPredicate: 'acl:agentGroup', agentUri: groupOf(BOB), mode: 'acl:Read', actorUri: BOB });
});
test('an existing, already protected collection is reused: no creation, no ACL write', async () => {
const existing = existingCollection(BOB, null);
existing.acls[existing.uri] = protectedAcl(existing.uri);
const j = join(MP1);
const w = setup({ acts: [j], stored: existing.stored, acls: existing.acls, items: existing.items });
await emitJoin(w, j);
expect(w.calls.some(c => c.action === 'pod-collections.createAndAttach')).toBe(false);
expect(w.calls.some(c => c.action === 'pod-permissions.add' || c.action === 'signature.proxy.query')).toBe(false);
expect(w.state.items[existing.uri]).toEqual([MP1]);
});
test('an existing collection still world-readable and not shared with the contacts is fixed at the Join', async () => {
const existing = existingCollection(BOB, null);
existing.acls[existing.uri] = createdCollectionAcl(existing.uri);
const j = join(MP1);
const w = setup({ acts: [j], stored: existing.stored, acls: existing.acls, items: existing.items });
await emitJoin(w, j);
expect(w.calls.some(c => c.action === 'pod-collections.createAndAttach')).toBe(false);
expect(hasOwnPublicRead(w.state.acls[existing.uri])).toBe(false);
expect(w.state.items[existing.uri]).toEqual([MP1]);
});
test('a public read the pod refuses to remove: nothing written, NOT recorded, loudly logged — the next act retries', async () => {
const j = join(MP1);
const w = setup({ acts: [j], fail: { putStatus: 403 } });
await emitJoin(w, j);
expect(attendees(w)).toEqual([]);
expect(participations(w)).toEqual([]);
expect(w.calls.some(c => c.action === 'pod-collections.add')).toBe(false);
expect(ledgerOf(w)).toBeUndefined();
expect(w.state.notifications).toEqual([]);
expect(w.logs.error.some(m => m.includes('public read') && m.includes('NOT recorded'))).toBe(true);
// The pod accepts the change again: the same Join, notified again, is applied in full.
const retry = world({ stored: { ...w.state.stored }, items: w.state.items, acls: w.state.acls });
await emitJoin(retry, j);
expect(attendees(retry)).toEqual([urnOf(BOB)]);
expect(hasOwnPublicRead(retry.state.acls[participationsUri(retry)])).toBe(false);
expect(participations(retry)).toEqual([MP1]);
expect(ledgerOf(retry)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: urnOf(BOB) });
});
test('a public read INHERITED from the container cannot be removed here: the act is not recorded', async () => {
const existing = existingCollection(BOB, null);
existing.acls[existing.uri] = [
...protectedAcl(existing.uri),
{ '@id': `http://localhost:3000/_acl/bob/data/as/collection#DefaultRead`, 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' }
];
const j = join(MP1);
const w = setup({ acts: [j], stored: existing.stored, acls: existing.acls, items: existing.items });
await emitJoin(w, j);
expect(w.state.items[existing.uri]).toEqual([]);
expect(ledgerOf(w)).toBeUndefined();
expect(w.logs.error.some(m => m.includes('inherited'))).toBe(true);
});
test('a refused group grant or an unreadable ACL: the act is not recorded', async () => {
for (const fail of [{ grant: true }, { aclRead: true }]) {
const j = join(MP1);
const w = setup({ acts: [j], fail });
await emitJoin(w, j);
expect(attendees(w)).toEqual([]);
expect(ledgerOf(w)).toBeUndefined();
expect(w.state.notifications).toEqual([]);
}
});
test('a participations write the pod refuses (the call resolves) leaves the act unrecorded (member sticky) and unnotified', async () => {
const j = join(MP1);
const w = setup({ acts: [j], fail: { participationsWrite: true } });
await emitJoin(w, j);
expect(w.state.refusedWrites.map(r => r.action)).toEqual(['pod-collections.add']);
// The act (its URI/stamp/kind) is unrecorded, but the member URN was persisted before the writes:
// the entry carries only that sticky member, so the next act reuses it instead of a second URN.
expect(ledgerOf(w)).toEqual({ member: urnOf(BOB) });
expect(w.state.notifications).toEqual([]);
expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes(`does not hold ${MP1}`))).toBe(true);
// The accepted residual: the list in the creator's pod was written; the next act re-applies both.
expect(attendees(w)).toEqual([urnOf(BOB)]);
const retry = world({ stored: { ...w.state.stored }, items: w.state.items, acls: w.state.acls });
await emitJoin(retry, j);
expect(participations(retry)).toEqual([MP1]);
expect(ledgerOf(retry).change).toBe('join');
expect(retry.state.notifications).toHaveLength(1); // the state the ledger held flips now
});
test('a Leave removes the meeting point from the participant\'s collection', async () => {
const j = join(MP1);
const l = leave(MP1);
const w = setup({ acts: [j, l] });
await emitJoin(w, j);
await emitLeave(w, l);
expect(attendees(w)).toEqual([]);
expect(participations(w)).toEqual([]);
expect(ledgerOf(w).change).toBe('leave');
const removed = w.calls.find(c => c.action === 'pod-collections.remove');
expect(removed.params).toEqual({ collectionUri: participationsUri(w), itemUri: MP1, actorUri: BOB });
});
test('a Leave when the participant has no collection: the list is updated, nothing created, the act recorded', async () => {
const l = leave(MP1);
const w = setup({ acts: [l], items: { [attendeesOf(MP1)]: [urnOf(BOB)] } });
await emitLeave(w, l);
expect(attendees(w)).toEqual([]);
expect(w.calls.some(c => ['pod-collections.createAndAttach', 'pod-collections.remove', 'pod-permissions.add'].includes(c.action))).toBe(false);
expect(ledgerOf(w).change).toBe('leave');
});
});
describe('registration — a pod failure the platform hides is never counted as applied', () => {
// The platform's `pod-collections.getItems` answers [] on a failed read, and its add/remove resolve
// when the pod refuses the PATCH: the backend reads with the status checked and reads every write back.
const listed = (w, mp = MP1) => ({ [attendanceSubject(mp, BOB)]: { activityUri: join(mp, '2026-10-29T09:00:00.000Z', BOB, 0).id, published: '2026-10-29T09:00:00.000Z', change: 'join', member: urnOf(BOB, mp) } });
const notRecorded = (w, contains) => expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes(contains))).toBe(true);
test("a Leave while the creator's pod fails the list read: NOT recorded, the URN stays, nobody notified — the same Leave, notified again, applies it", async () => {
const l = leave(MP1);
const existing = existingCollection(BOB, protectedAcl(`${BOB}/data/as/collection/participations-existing`));
existing.items[existing.uri] = [MP1];
const fail = { read: { [attendeesOf(MP1)]: 500 } };
const w = setup({ acts: [l], stored: existing.stored, acls: existing.acls, items: { ...existing.items, [attendeesOf(MP1)]: [urnOf(BOB)] }, ledger: listed(), fail });
// What the platform's own read would have said: nobody listed.
expect(await w.ctx.call('pod-collections.getItems', { collectionUri: attendeesOf(MP1), actorUri: ALICE })).toEqual([]);
await emitLeave(w, l);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(w.state.items[existing.uri]).toEqual([MP1]); // nothing written to either collection
expect(writes(w.calls)).toEqual([]);
expect(ledgerOf(w).change).toBe('join');
expect(w.state.notifications).toEqual([]);
notRecorded(w, `${attendeesOf(MP1)} (HTTP 500)`);
delete fail.read;
await emitLeave(w, l);
expect(attendees(w)).toEqual([]);
expect(w.state.items[existing.uri]).toEqual([]);
expect(ledgerOf(w)).toMatchObject({ activityUri: l.id, change: 'leave' });
expect(w.state.notifications).toHaveLength(1);
});
test('a Join whose list write the pod refuses: NOT recorded, nothing notified, the meeting point kept out of the participant\'s collection — the same Join, notified again, applies it', async () => {
const j = join(MP1);
const fail = { attendeesWrite: true };
const w = setup({ acts: [j], fail });
await emitJoin(w, j);
expect(w.state.refusedWrites.map(r => r.action)).toEqual(['attendees.add']);
expect(attendees(w)).toEqual([]);
expect(participations(w)).toEqual([]);
expect(w.calls.some(c => c.action === 'pod-collections.add')).toBe(false);
expect(ledgerOf(w)).toEqual({ member: urnOf(BOB) }); // the sticky member only
expect(w.state.notifications).toEqual([]);
notRecorded(w, `does not hold ${urnOf(BOB)}`);
fail.attendeesWrite = false;
await emitJoin(w, j);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(participations(w)).toEqual([MP1]);
expect(ledgerOf(w)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: urnOf(BOB) });
expect(w.state.notifications).toHaveLength(1);
});
test('a Leave whose list removal the pod refuses: NOT recorded, the URN stays, nobody notified', async () => {
const l = leave(MP1);
const w = setup({ acts: [l], items: { [attendeesOf(MP1)]: [urnOf(BOB)] }, ledger: listed(), fail: { attendeesWrite: true } });
await emitLeave(w, l);
expect(w.state.refusedWrites.map(r => r.action)).toEqual(['attendees.remove']);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(ledgerOf(w).change).toBe('join');
expect(w.state.notifications).toEqual([]);
notRecorded(w, `still holds ${urnOf(BOB)}`);
});
test('a Leave whose participations removal the pod refuses: NOT recorded, not notified; the list removal stands (the accepted residual) and the next Leave completes it', async () => {
const j = join(MP1);
const l = leave(MP1);
const fail = {};
const w = setup({ acts: [j, l], fail });
await emitJoin(w, j);
fail.participationsWrite = true;
await emitLeave(w, l);
expect(attendees(w)).toEqual([]);
expect(participations(w)).toEqual([MP1]);
expect(ledgerOf(w).change).toBe('join');
expect(w.state.notifications).toHaveLength(1); // the Join's only
notRecorded(w, `still holds ${MP1}`);
fail.participationsWrite = false;
await emitLeave(w, l);
expect(participations(w)).toEqual([]);
expect(ledgerOf(w).change).toBe('leave');
expect(w.state.notifications).toHaveLength(2); // the state the ledger held flips now
});
test('a write that cannot be read back is not counted: NOT recorded, not notified', async () => {
const j = join(MP1);
const w = setup({ acts: [j], fail: { read: { [`${BOB}/data/as/collection/participations-1`]: 403 } } });
await emitJoin(w, j);
expect(participations(w)).toEqual([MP1]); // the write landed, but nothing could show it
expect(ledgerOf(w)).toEqual({ member: urnOf(BOB) });
expect(w.state.notifications).toEqual([]);
notRecorded(w, 'participations-1 (HTTP 403)');
});
test('a collection the pod creates but does not link to the profile is never used: nothing written, nothing recorded — the next Join links a new one', async () => {
const j = join(MP1);
const fail = { attach: true };
const w = setup({ acts: [j], fail });
await emitJoin(w, j);
expect(w.state.created).toHaveLength(1);
expect(participationsUri(w)).toBeUndefined();
expect(w.state.items[w.state.created[0]]).toEqual([]);
expect(attendees(w)).toEqual([]);
expect(w.calls.some(c => ['attendees.add', 'pod-collections.add', 'pod-permissions.add', 'signature.proxy.query'].includes(c.action))).toBe(false);
expect(ledgerOf(w)).toBeUndefined();
expect(w.state.notifications).toEqual([]);
expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes('did not link it'))).toBe(true);
fail.attach = false;
await emitJoin(w, j);
expect(w.state.created).toHaveLength(2);
expect(participationsUri(w)).toBe(w.state.created[1]);
expect(participations(w)).toEqual([MP1]);
expect(w.state.items[w.state.created[0]]).toEqual([]); // the unlinked one stays unused
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(ledgerOf(w).change).toBe('join');
});
});
describe("registration — attendance follows the participant's LATEST act, whatever order notifications run in", () => {
test('a stale act (the ledger holds a newer Leave) changes nothing and notifies no one', async () => {
const j = join(MP1);
const l = leave(MP1);
const w = setup({ acts: [j, l], ledger: { [attendanceSubject(MP1, BOB)]: { activityUri: l.id, published: T1, change: 'leave', member: urnOf(BOB) } } });
await emitJoin(w, j);
expect(writes(w.calls)).toEqual([]);
expect(w.logs.info.some(m => m.includes('not newer than the last act applied'))).toBe(true);
});
test('the same Join notified twice (in turn, then concurrently): applied once, notified once', async () => {
const j = join(MP1);
let w = setup({ acts: [j] });
await emitJoin(w, j);
await emitJoin(w, j);
expect(w.calls.filter(c => c.action === 'attendees.add')).toHaveLength(1);
expect(w.state.notifications).toHaveLength(1);
w = setup({ acts: [j] });
await Promise.all([emitJoin(w, j), emitJoin(w, j)]);
expect(w.calls.filter(c => c.action === 'attendees.add')).toHaveLength(1);
expect(w.state.notifications).toHaveLength(1);
});
test('join then leave, the Leave handled first: ends NOT attending, out of the collection', async () => {
const j = join(MP1);
const l = leave(MP1);
const w = setup({ acts: [j, l], delays: { [`read:${j.id}`]: 40 } });
const joining = emitJoin(w, j);
await new Promise(resolve => setTimeout(resolve, 5));
await Promise.all([joining, emitLeave(w, l)]);
expect(attendees(w)).toEqual([]);
expect(participations(w)).toEqual([]);
expect(ledgerOf(w).change).toBe('leave');
});
test('leave then join, the Join handled first: ends attending, in the collection', async () => {
const l = leave(MP1, T0);
const j = join(MP1, T1);
const w = setup({ acts: [j, l], delays: { [`read:${l.id}`]: 60 } });
const leaving = emitLeave(w, l);
await new Promise(resolve => setTimeout(resolve, 5));
await Promise.all([leaving, emitJoin(w, j)]);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(participations(w)).toEqual([MP1]);
expect(ledgerOf(w).change).toBe('join');
});
test('a newer Join while already attending is recorded but notifies no one; so is a Leave of someone who never joined', async () => {
const j1 = join(MP1, T0);
const j2 = join(MP1, T1, BOB, 2);
let w = setup({ acts: [j1, j2] });
await emitJoin(w, j1);
await emitJoin(w, j2);
expect(ledgerOf(w).activityUri).toBe(j2.id);
expect(w.state.notifications).toHaveLength(1);
const l = leave(MP1);
w = setup({ acts: [l] });
await emitLeave(w, l);
expect(ledgerOf(w).change).toBe('leave');
expect(w.state.notifications).toEqual([]);
});
});
describe('registration — the creator is notified ALWAYS anonymously (the contacts list is forgeable)', () => {
const notified = async ({ change = 'join', fail } = {}) => {
const j = join(MP1);
const l = leave(MP1);
const w = setup({ acts: [j, l], fail });
await emitJoin(w, j);
if (change === 'leave') await emitLeave(w, l);
return w;
};
test('a Join: anonymous — no name, no WebID, no activity; the participant\'s contacts list is never read', async () => {
const w = await notified();
const [n] = w.state.notifications;
expect(n.recipientUri).toBe(ALICE); // Alice is the creator; Bob the participant
expect(n.template.title.fr).toContain('Un participant a rejoint');
expect(n.template.title.en).toContain('A participant joined');
expect(JSON.stringify(n.template)).not.toContain('participantName');
expect(n).not.toHaveProperty('participantName');
expect(n).not.toHaveProperty('activity');
expect(JSON.stringify(n)).not.toContain(BOB); // never the participant's WebID
// The only read of a /contacts collection a naming notification would have made is gone.
const readsOf = uri => w.calls.filter(c => ['pod-resources.get', 'pod-collections.getItems'].includes(c.action) && (c.params.resourceUri ?? c.params.collectionUri) === uri);
expect(readsOf(contactsOf(BOB))).toEqual([]);
});
test('a Leave: the withdrawal wording, also anonymous', async () => {
const w = await notified({ change: 'leave' });
const n = w.state.notifications[1];
expect(n.template.title.fr).toContain('Un participant a quitté');
expect(n.template.title.en).toContain('A participant left');
expect(n).not.toHaveProperty('participantName');
expect(n).not.toHaveProperty('activity');
});
test('the meeting point is named through template parameters; the link opens its event\'s meeting points', async () => {
const w = await notified();
const [n] = w.state.notifications;
expect(n.meetingPointName).toBe('Rendez-vous mp1');
expect(n.eventUri).toBe(EVENT1);
expect(n.context).toBe(MP1);
expect(n.template.title.fr).toContain('{{{meetingPointName}}}');
expect(n.template.title.en).toContain('{{{meetingPointName}}}');
expect(n.template.actions).toEqual([{ caption: { en: 'View', fr: 'Voir' }, link: '/events/{{encodeUri eventUri}}/meeting-points' }]);
});
test('a notification failure does not fail the act', async () => {
const w = await notified({ fail: { notify: true } });
expect(ledgerOf(w).change).toBe('join');
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(w.logs.error.some(m => m.includes('Could not notify'))).toBe(true);
});
test('a creator joining their own meeting point is not notified', async () => {
const j = join(MP1, T0, ALICE);
const w = setup({ acts: [j] });
await emitJoin(w, j);
expect(attendees(w)).toEqual([urnOf(ALICE)]);
expect(w.state.notifications).toEqual([]);
});
});
describe('registration — a write in flight at the timeout (no re-alignment: decided limit)', () => {
// Bob joined then left (Leave L0 recorded). He acts once more; that act's write is sent before the
// timeout and lands after it; nothing follows. The late write is the latest act: it must stand.
const L0 = leave(MP1, '2026-10-29T09:00:00.000Z', BOB, 0);
const J0 = join(MP1, '2026-10-29T09:00:00.000Z', BOB, 0);
const subject = () => attendanceSubject(MP1, BOB);
test('a late, unrecorded REJOIN is not undone by the older recorded Leave: ends attending', async () => {
const j = join(MP1);
const w = setup({ acts: [j, L0], delays: { 'attendees.add': 150 }, timeoutMs: 80, ledger: { [subject()]: { activityUri: L0.id, published: L0.published, change: 'leave', member: urnOf(BOB) } } });
await emitJoin(w, j);
await new Promise(resolve => setTimeout(resolve, 300));
expect(attendees(w)).toEqual([urnOf(BOB)]);
});
test('the mirror case: a late, unrecorded LEAVE is not undone by the older recorded Join: ends NOT attending', async () => {
const l = leave(MP1);
const w = setup({
acts: [l, J0],
items: { [attendeesOf(MP1)]: [urnOf(BOB)] },
delays: { 'attendees.remove': 150 },
timeoutMs: 80,
ledger: { [subject()]: { activityUri: J0.id, published: J0.published, change: 'join', member: urnOf(BOB) } }
});
await emitLeave(w, l);
await new Promise(resolve => setTimeout(resolve, 300));
expect(attendees(w)).toEqual([]);
});
test('KNOWN LIMIT (documented in updateAttendance): an older Join whose add lands after a newer Leave stays applied until the next act', async () => {
const j = join(MP1);
const l = leave(MP1);
const w = setup({ acts: [j, l], delays: { 'attendees.add': 150 }, timeoutMs: 80 });
const joining = emitJoin(w, j);
await new Promise(resolve => setTimeout(resolve, 10));
await Promise.all([joining, emitLeave(w, l)]);
await new Promise(resolve => setTimeout(resolve, 300));
expect(ledgerOf(w).change).toBe('leave'); // the newer act is the recorded one
expect(attendees(w)).toEqual([urnOf(BOB)]); // the late add of the older Join stands — no re-alignment
});
});
describe('registration — a participant who rewrites their profile secret between acts stays ONE member', () => {
const rotate = (w, who, secret) => {
w.state.stored[profileOf(who)] = profile(who, { [FP_SECRET]: secret });
};
const urnWith = (who, secret, mp = MP1) => memberUrn({ participantUri: who, eventUri: EVENT_OF[mp], secret });
const secretN = n => String(n).repeat(64);
test('Join, rotate, Join: one URN in the list; then Leave empties it', async () => {
const acts = [1, 2, 3, 4, 5].map(n => join(MP1, `2026-10-29T10:00:0${n}.000Z`, BOB, n));
const l = leave(MP1, '2026-10-29T10:00:09.000Z', BOB, 9);
const w = setup({ acts: [...acts, l] });
for (const [i, a] of acts.entries()) {
rotate(w, BOB, secretN(i + 1));
await emitJoin(w, a);
}
expect(attendees(w)).toEqual([urnWith(BOB, secretN(1))]);
expect(w.state.notifications).toHaveLength(1);
expect(ledgerOf(w)).toEqual({ activityUri: acts[4].id, published: acts[4].published, change: 'join', member: urnWith(BOB, secretN(1)) });
rotate(w, BOB, secretN(6));
await emitLeave(w, l);
expect(attendees(w)).toEqual([]);
expect(participations(w)).toEqual([]);
expect(ledgerOf(w)).toEqual({ activityUri: l.id, published: l.published, change: 'leave', member: urnWith(BOB, secretN(1)) });
});
test('Join, rotate, Leave, Join: still the FIRST URN — the member is sticky per pair, never recomputed', async () => {
const j1 = join(MP1, T0, BOB, 1);
const l = leave(MP1, T1, BOB, 1);
const j2 = join(MP1, T2, BOB, 2);
const w = setup({ acts: [j1, l, j2] });
await emitJoin(w, j1);
rotate(w, BOB, secretN(7));
await emitLeave(w, l);
expect(attendees(w)).toEqual([]);
await emitJoin(w, j2);
// The ledger kept the URN chosen at the first Join; the rotated secret is never hashed again.
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w)).not.toContain(urnWith(BOB, secretN(7)));
});
// THE DEFECT the sticky member fixes: a Join whose list add lands but a later step fails is left
// unrecorded; the participant rewrites their secret and joins again — that must not list a second URN.
const onEmitWith = (w, ctx, key, act) => registration.activities[key].onEmit.call(w.registration, ctx, act, act.actor);
test('partial failure (the pod refuses the participations write after the list add) + secret rewrite + Join: ONE URN; a Leave empties the list', async () => {
const j1 = join(MP1, T0, BOB, 1);
const j2 = join(MP1, T1, BOB, 2);
const l = leave(MP1, T2, BOB, 3);
const fail = { participationsWrite: true };
const w = setup({ acts: [j1, j2, l], fail });
await emitJoin(w, j1);
expect(attendees(w)).toEqual([urnOf(BOB)]); // the add landed…
expect(ledgerOf(w).activityUri).toBeUndefined(); // …the act did not get recorded
fail.participationsWrite = false;
rotate(w, BOB, secretN(4));
await emitJoin(w, j2);
expect(attendees(w)).toEqual([urnOf(BOB)]); // ONE URN — not a second one from the new secret
expect(ledgerOf(w)).toEqual({ activityUri: j2.id, published: T1, change: 'join', member: urnOf(BOB) });
await emitLeave(w, l);
expect(attendees(w)).toEqual([]);
expect(participations(w)).toEqual([]);
});
test('partial failure at the ledger record (after both writes) + secret rewrite + Join: ONE URN; a Leave empties the list', async () => {
const j1 = join(MP1, T0, BOB, 1);
const j2 = join(MP1, T1, BOB, 2);
const l = leave(MP1, T2, BOB, 3);
const w = setup({ acts: [j1, j2, l] });
let failRecord = true;
const ctx = {
call: async (action, params) => {
if (action === 'triplestore.update' && failRecord && params.query.includes('attendanceAct')) {
failRecord = false;
throw new Error('the ledger record failed');
}
return w.ctx.call(action, params);
}
};
await onEmitWith(w, ctx, 'join', j1);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(ledgerOf(w).activityUri).toBeUndefined();
rotate(w, BOB, secretN(5));
await onEmitWith(w, ctx, 'join', j2);
expect(attendees(w)).toEqual([urnOf(BOB)]);
await onEmitWith(w, ctx, 'leave', l);
expect(attendees(w)).toEqual([]);
});
test('a member persist still in flight at the timeout + secret rewrite + Join: the late write replaces nothing — ONE URN; a Leave empties the list', async () => {
const j1 = join(MP1, T0, BOB, 1);
const j2 = join(MP1, T1, BOB, 2);
const l = leave(MP1, T2, BOB, 3);
const w = setup({ acts: [j1, j2, l], timeoutMs: 80 });
let slowOnce = true;
const ctx = {
call: async (action, params) => {
if (action === 'triplestore.update' && slowOnce && !params.query.includes('attendanceAct')) {
slowOnce = false;
await new Promise(resolve => setTimeout(resolve, 150)); // lands after the 80 ms limit
}
return w.ctx.call(action, params);
}
};
const first = onEmitWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
await new Promise(resolve => setTimeout(resolve, 100));
rotate(w, BOB, secretN(6));
await onEmitWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
await first;
await new Promise(resolve => setTimeout(resolve, 150));
expect(attendees(w)).toEqual([urnWith(BOB, secretN(6))]);
expect(ledgerOf(w).member).toBe(urnWith(BOB, secretN(6))); // the late write was a no-op
await onEmitWith(w, ctx, 'leave', l);
expect(attendees(w)).toEqual([]);
});
test('a Join of an attending participant re-adds the recorded URN when it is missing from the list', async () => {
const j1 = join(MP1, T0, BOB, 1);
const j2 = join(MP1, T1, BOB, 2);
const w = setup({ acts: [j1, j2] });
await emitJoin(w, j1);
w.state.items[attendeesOf(MP1)] = [];
rotate(w, BOB, secretN(8));
await emitJoin(w, j2);
expect(attendees(w)).toEqual([urnOf(BOB)]);
});
test('a Leave with no Join recorded computes the URN from the current secret', async () => {
const l = leave(MP1);
const w = setup({ acts: [l], items: { [attendeesOf(MP1)]: [urnOf(BOB)] } });
await emitLeave(w, l);
expect(attendees(w)).toEqual([]);
});
test('two participants never share an entry', async () => {
const jb = join(MP1, T0, BOB);
const jc = join(MP1, T0, CAROL);
const w = setup({ acts: [jb, jc] });
await emitJoin(w, jb);
await emitJoin(w, jc);
expect(attendanceSubject(MP1, BOB)).not.toBe(attendanceSubject(MP1, CAROL));
expect(ledgerOf(w, MP1, BOB).member).toBe(urnOf(BOB));
expect(ledgerOf(w, MP1, CAROL).member).toBe(urnOf(CAROL));
});
test('an entry under the old (meeting point, URN) key is not found: nothing recorded', async () => {
const j = join(MP1);
const old = attendanceSubject(MP1, urnOf(BOB));
const w = setup({ acts: [j], ledger: { [old]: { activityUri: `${BOB}/zz`, published: '2030-01-01T00:00:00.000Z', change: 'leave', member: urnOf(BOB) } } });
await emitJoin(w, j);
expect(attendees(w)).toEqual([urnOf(BOB)]);
});
});
describe('registration — matchers never throw', () => {
test('routing is type-only: the object is never fetched; a garbage activity reports no match', async () => {
const logs = { error: [] };
const svc = { name: 'registration', logger: { error: m => logs.error.push(m) } };
const failing = async () => {
throw new Error('proxy unreachable');
};
for (const [key, type] of [['join', 'Join'], ['leave', 'Leave']]) {
const routed = await registration.activities[key].match.call(svc, { type, object: MP1 }, failing);
expect(routed.match).toBe(true);
}
const unmatched = await registration.activities.join.match.call(svc, { type: 'Create', object: MP1 }, failing);
expect(unmatched.match).toBe(false);
expect(logs.error).toHaveLength(0);
});
});
describe('attendance-ledger — the order of acts', () => {
const at = (published, activityUri = join(MP1).id) => ({ activityUri, published });
test('a later stamp is newer; an earlier one is not; a tie is broken by the activity URI', () => {
expect(isNewerAct(at('2026-10-13T10:00:01Z'), at('2026-10-13T10:00:00Z'))).toBe(true);
expect(isNewerAct(at('2026-10-13T09:59:59Z'), at('2026-10-13T10:00:00Z'))).toBe(false);
expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/b`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(true);
expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/a`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(false); // the same act twice
});
test('the first act is always newer; an unstamped act never is', () => {
expect(isNewerAct(at('2026-10-13T10:00:00Z'), undefined)).toBe(true);
expect(isNewerAct(at(undefined), undefined)).toBe(false);
expect(isNewerAct(at('not a date'), undefined)).toBe(false);
});
test('one entry per (meeting point, member URN), under a URN the store accepts', () => {
expect(attendanceSubject(MP1, BOB)).not.toBe(attendanceSubject(MP1, CAROL));
expect(attendanceSubject(MP1, BOB)).toMatch(/^urn:festipod:attendance:[^<>"{}|^`\\ ]+$/);
expect(() => lastAttendanceQuery(MP1, BOB)).not.toThrow();
});
});