Files
festipod/backend/services/registration.service.test.js
T
2026-11-06 22:57:46 +01:00

941 lines
46 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) => w.deliver('join', act);
const emitLeave = (w, act) => w.deliver('leave', act);
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 delivered to the app's inbox", () => {
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 delivered body is a pointer: its embedded object is ignored, the stored meeting point is used', async () => {
const j = join(MP1);
const w = setup({ acts: [j] });
await emitJoin(w, { ...j, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } });
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w, MP2)).toEqual([]);
expect(w.state.items[`${MALLORY}/data/c`]).toBeUndefined();
});
test("nobody joins or leaves for someone else: a delivery claiming Mallory for Bob's activity, or Bob for Mallory's, writes nothing", async () => {
const j = join(MP1);
const forged = { ...join(MP1, T0, MALLORY), actor: BOB };
const w = setup({ acts: [j, forged] });
await emitJoin(w, { ...j, actor: MALLORY });
await emitJoin(w, forged);
expect(writes(w.calls)).toEqual([]);
expect(attendees(w)).toEqual([]);
});
test("the meeting point is the one its creator's pod serves, not the copy the participant's server hands over", async () => {
// Bob's server answers the reads made through him: for MP1 (in Alice's pod), a forged copy
// naming MP2's attendee list and a name of its choosing.
const j = join(MP1);
const forged = meetingPoint(MP1, { name: 'Forged title', 'apods:attendees': attendeesOf(MP2) });
const w = setup({ acts: [j], proxied: { [BOB]: { [MP1]: forged } } });
await emitJoin(w, j);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w, MP2)).toEqual([]);
expect(w.state.notifications.map(n => n.meetingPointName)).toEqual(['Rendez-vous mp1']);
expect(w.calls.some(c => c.action === 'pod-resources.get' && c.params.resourceUri === MP1 && c.params.actorUri === ALICE)).toBe(true);
});
test("a meeting point its creator's pod does not serve, or serves under another creator, writes nothing", async () => {
const j = join(MP1);
for (const options of [
{ fail: { read: { [MP1]: 404 } }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `does not return the meeting point ${MP1}` },
{ stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': CAROL }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `not in the pod of its creator ${CAROL}` },
{ stored: { [MP1]: meetingPoint(MP1, { type: 'Note' }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: 'is not an Event' }
]) {
const { contains, stored = {}, ...rest } = options;
const w = setup({ acts: [j], stored, ...rest });
await emitJoin(w, j);
expect(writes(w.calls)).toEqual([]);
expect(w.logs.warn.some(m => m.includes(contains))).toBe(true);
}
});
test("the act is handled on reception in the app's inbox only: no outbox handler", () => {
expect(registration.mixins).toContain(require('@semapps/activitypub').ActivitiesHandlerMixin);
expect(registration.activities.join.onEmit).toBeUndefined();
expect(registration.activities.leave.onEmit).toBeUndefined();
expect(typeof registration.activities.join.onReceive).toBe('function');
expect(typeof registration.activities.leave.onReceive).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('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 deliverWith = (w, ctx, key, act) => registration.activities[key].onReceive.call(w.registration, ctx, act, APP);
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 deliverWith(w, ctx, 'join', j1);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(ledgerOf(w).activityUri).toBeUndefined();
rotate(w, BOB, secretN(5));
await deliverWith(w, ctx, 'join', j2);
expect(attendees(w)).toEqual([urnOf(BOB)]);
await deliverWith(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 = deliverWith(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 deliverWith(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 deliverWith(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();
});
});
describe('registration — a direct sign-up: a Join naming an event itself', () => {
const DAVE = ALICE.replace('/alice', '/dave');
const PLAIN = `${ALICE}/data/events/plain`;
const plainEvent = (uri, extra = {}) => meetingPoint(uri, { context: undefined, name: `Evénement ${uri.split('/').pop()}`, ...extra });
const plainWorld = (acts, options = {}) => setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) }, ...options });
const plainUrn = (who = BOB, event = PLAIN) => memberUrn({ participantUri: who, eventUri: event, secret: SECRETS[who] });
test("the member URN, computed with the event as anchor, joins the event's own list and the event joins the participant's collection", async () => {
const j = join(PLAIN);
const w = plainWorld([j]);
await emitJoin(w, j);
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
expect(attendees(w, PLAIN)).not.toContain(BOB);
const add = w.calls.find(c => c.action === 'attendees.add');
expect(add.params).toEqual({ collectionUri: attendeesOf(PLAIN), itemUri: plainUrn(), actorUri: ALICE });
expect(participations(w)).toEqual([PLAIN]);
const added = w.calls.find(c => c.action === 'pod-collections.add');
expect(added.params).toEqual({ collectionUri: participationsUri(w), itemUri: PLAIN, actorUri: BOB });
expect(ledgerOf(w, PLAIN)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: plainUrn() });
expect(w.logs.error).toEqual([]);
});
test("a direct sign-up notifies nobody: the declarer of an event is not its host", async () => {
const j = join(PLAIN);
const w = plainWorld([j]);
await emitJoin(w, j);
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
expect(w.calls.some(c => c.action === 'pod-notifications.send')).toBe(false);
expect(w.state.notifications).toEqual([]);
});
test("the participant carries the SAME URN in the event's own list and in its meeting points' lists (R5, R6)", async () => {
const festival = plainEvent(EVENT1, { 'dc:creator': DAVE });
const acts = [join(EVENT1), join(MP1), join(MP2)];
const w = setup({ acts, stored: { [EVENT1]: festival } });
for (const a of acts) await emitJoin(w, a);
expect(attendees(w, EVENT1)).toEqual([urnOf(BOB, MP1)]);
expect(attendees(w, MP1)).toEqual(attendees(w, EVENT1));
expect(attendees(w, MP2)).toEqual(attendees(w, EVENT1));
expect(participations(w).sort()).toEqual([EVENT1, MP1, MP2].sort());
});
test('signing up twice leaves one entry in the list and one in the collection (R1)', async () => {
const first = join(PLAIN, T0, BOB, 1);
const again = join(PLAIN, T1, BOB, 2);
const w = plainWorld([first, again]);
await emitJoin(w, first);
await emitJoin(w, again);
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
expect(participations(w)).toEqual([PLAIN]);
expect(w.logs.error).toEqual([]);
});
test("a Leave naming the event removes the URN from its list and the event from the participant's collection (R8)", async () => {
const j = join(PLAIN);
const l = leave(PLAIN);
const w = plainWorld([j, l]);
await emitJoin(w, j);
await emitLeave(w, l);
expect(attendees(w, PLAIN)).toEqual([]);
expect(participations(w)).toEqual([]);
expect(ledgerOf(w, PLAIN).change).toBe('leave');
});
test("leaving the event leaves the participant's meeting point memberships alone, and the other way round", async () => {
const acts = [join(PLAIN), join(MP1), leave(PLAIN, T1)];
const w = setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) } });
await emitJoin(w, acts[0]);
await emitJoin(w, acts[1]);
expect(participations(w).sort()).toEqual([PLAIN, MP1].sort());
await emitLeave(w, acts[2]);
expect(attendees(w, MP1)).toEqual([urnOf(BOB, MP1)]);
expect(participations(w)).toEqual([MP1]);
});
test('a stored object that is not an Event stays refused', async () => {
const j = join(PLAIN);
const w = plainWorld([j], { stored: { [PLAIN]: { ...plainEvent(PLAIN), type: 'Note' } } });
await emitJoin(w, j);
expect(writes(w.calls)).toEqual([]);
expect(w.logs.warn.length).toBeGreaterThan(0);
});
test("an event not in the pod of its dc:creator, or naming no creator, stays refused", async () => {
const j = join(PLAIN);
for (const extra of [{ 'dc:creator': MALLORY }, { 'dc:creator': undefined }]) {
const w = plainWorld([j], { stored: { [PLAIN]: plainEvent(PLAIN, extra) } });
await emitJoin(w, j);
expect(writes(w.calls)).toEqual([]);
expect(attendees(w, PLAIN)).toEqual([]);
expect(w.logs.warn.length).toBeGreaterThan(0);
}
});
});