Files
festipod/backend/services/emitted-activity.test.js
Sylvain Duchesne 9e2df2478b Deliver Join and Leave to the app's inbox instead of an unaddressed outbox post
A participation activity is now addressed to the Festipod app actor alone and
handled on reception in the app's inbox, the way the ActivityPods developer
advised: use the ActivityPub protocol rather than a side channel. Any
compatible sender may deliver; the inbox accepts only the activity's own
signer, and the backend still requires the activity to lie in that actor's pod
and acts on the stored copy.

The meeting point is read again in its creator's pod before anything is
written: read through the participant's pod, a foreign server's proxy chose
the attendee list and the notification's name.

Confidentiality is unchanged: the Join stays readable by its author and the
app only. Recorded in decision_2026-11-03_participation-delivered-to-the-app,
a named exception to festipod-only-reference-app-patterns.
2026-11-03 18:31:24 +01:00

392 lines
22 KiB
JavaScript

const { test, expect, describe } = require('bun:test');
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, verifiedDelivery, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
const ALICE = 'http://localhost:3000/alice';
const BOB = 'http://localhost:3000/bob';
// The activity and object URIs a genuine Festipod creation produces: both under the actor's pod.
const ACT = `${ALICE}/outbox/act-1`;
const OBJ = `${ALICE}/data/events/e1`;
/** A `read(uri)` built from a map of uri → { status, body }; a missing uri answers 404. */
const reader = pod => async uri => pod[uri] || { status: 404, body: undefined };
const createActivity = { id: ACT, type: 'Create', actor: ALICE, object: OBJ };
const event = (extra = {}) => ({ id: OBJ, type: 'Event', name: 'Concert', ...extra });
describe('verifyEmittedActivity — the webhook is a hint; the pod is the truth', () => {
test('the normal case: a Create emitted by the actor, re-read, is accepted with the STORED object', async () => {
const stored = event({ to: 'as:Public', name: 'the real, stored name' });
const read = reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: stored } });
// The webhook copy carries a DIFFERENT, attacker-chosen object body — it must be ignored.
const webhookActivity = { id: ACT, type: 'Create', actor: ALICE, object: { id: OBJ, type: 'Event', to: 'as:Public', name: 'forged' } };
const r = await verifyEmittedActivity({ activity: webhookActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(true);
expect(r.objectUri).toBe(OBJ);
expect(r.object.name).toBe('the real, stored name');
});
test('the activity is re-read even when the webhook embeds a full activity node', async () => {
const calls = [];
const read = async uri => {
calls.push(uri);
return uri === ACT ? { status: 200, body: createActivity } : { status: 200, body: event() };
};
const embedded = { id: ACT, type: 'Create', actor: ALICE, object: event() };
const r = await verifyEmittedActivity({ activity: embedded, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(true);
expect(calls).toEqual([ACT, OBJ]); // both re-read from the pod, nothing taken from the body
});
test('FORGERY: a victim\'s private event named through the attacker\'s outbox is refused', async () => {
// The attacker (Alice) owns her webhook URL; she forges a Create naming her own outbox as
// target and a VICTIM\'s private event as the object, addressed to Public. The watcher resolves
// actorUri = Alice (her outbox). The embedded object claims BOB\'s event is public.
const victimEvent = `${BOB}/data/events/private`;
const forged = { id: `${ALICE}/outbox/forged`, type: 'Create', actor: ALICE, object: { id: victimEvent, type: 'Event', to: 'as:Public' } };
// Even if the pod happens to return the forged activity (it is in Alice\'s pod), its object is
// in BOB\'s pod → refused at the object-ownership check, before any read of BOB\'s event.
const read = reader({ [`${ALICE}/outbox/forged`]: { status: 200, body: forged } });
const r = await verifyEmittedActivity({ activity: forged, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not in the pod of');
});
test('a Create the pod does not actually store (only in the webhook body) is refused', async () => {
const read = reader({}); // the pod returns 404 for the activity
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('does not return the activity');
});
test('a stored activity attributed to someone else is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Create', actor: BOB, object: OBJ } } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not attributed to');
});
test('a stored activity of the wrong type is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Announce', actor: ALICE, object: OBJ } } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('is not a Create');
});
test('an activity with no id (never stored) is refused before any read', async () => {
let reads = 0;
const read = async () => { reads++; return { status: 404 }; };
const r = await verifyEmittedActivity({ activity: { type: 'Create', actor: ALICE, object: event() }, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('no id');
expect(reads).toBe(0);
});
test('a Delete is accepted only when the pod says the object is gone or a Tombstone', async () => {
const del = { id: `${ALICE}/outbox/del`, type: 'Delete', actor: ALICE, object: OBJ };
const act = { [`${ALICE}/outbox/del`]: { status: 200, body: del } };
// Object gone → accepted.
let r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 404 } }) });
expect(r.ok).toBe(true);
expect(r.objectUri).toBe(OBJ);
// Tombstone → accepted.
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: { id: OBJ, type: 'Tombstone' } } }) });
expect(r.ok).toBe(true);
// Object still live → refused (the deletion is not true).
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: event() } }) });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not deleted');
});
});
describe('verifiedEmit — runs the handler only on an accepted activity, never throws', () => {
function svc(pod) {
const logs = { info: [], warn: [], error: [] };
const ran = [];
const service = {
name: 'test',
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
};
const ctx = { call: async (action, { resourceUri }) => (pod[resourceUri] || { status: 404 }) };
const onEmit = verifiedEmit('Create', async function (_ctx, verdict, actorUri) {
ran.push({ objectUri: verdict.objectUri, actorUri });
}, { objectType: 'Event' }).bind(service);
return { onEmit, ctx, logs, ran };
}
test('an accepted Create runs the handler with the stored object and the watcher\'s actor', async () => {
const { onEmit, ctx, ran } = svc({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: event() } });
await onEmit(ctx, createActivity, ALICE);
expect(ran).toEqual([{ objectUri: OBJ, actorUri: ALICE }]);
});
test('a refused Create logs a warning and does NOT run the handler', async () => {
const { onEmit, ctx, logs, ran } = svc({}); // pod returns 404 for the activity
await onEmit(ctx, createActivity, ALICE);
expect(ran).toEqual([]);
expect(logs.warn).toHaveLength(1);
expect(logs.warn[0]).toContain('ignored a Create');
});
test('a throwing read is caught and logged, never propagated', async () => {
const service = { name: 'test', logger: { info() {}, warn() {}, error(m) { this._e = m; } } };
const onEmit = verifiedEmit('Create', async () => {}, { objectType: 'Event' }).bind(service);
const ctx = { call: async () => { throw new Error('proxy down'); } };
await expect(onEmit(ctx, createActivity, ALICE)).resolves.toBeUndefined();
expect(service.logger._e).toContain('proxy down');
});
});
describe('neverThrowingMatcher', () => {
test('a throwing matcher reports no match and logs', async () => {
const service = { name: 'test', logger: { error(m) { this._e = m; } } };
const wrapped = neverThrowingMatcher(async () => { throw new Error('boom'); }).bind(service);
const r = await wrapped({ id: ACT }, async () => false);
expect(r.match).toBe(false);
expect(service.logger._e).toContain('boom');
});
});
describe('the stored object must be of the type the handler acts on', () => {
test('a public Note re-read at the object URI is refused for an Event handler; an Event is accepted', async () => {
const note = { id: OBJ, type: 'Note', to: 'as:Public', content: 'not an event' };
let r = await verifyEmittedActivity({
activity: createActivity,
actorUri: ALICE,
type: 'Create',
objectType: 'Event',
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: note } })
});
expect(r.ok).toBe(false);
expect(r.reason).toContain('is not a Event');
for (const type of ['Event', 'as:Event', 'https://www.w3.org/ns/activitystreams#Event', ['Event', 'apods:MeetingPoint']]) {
r = await verifyEmittedActivity({
activity: createActivity,
actorUri: ALICE,
type: 'Create',
objectType: 'Event',
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: { id: OBJ, type } } })
});
expect(r.ok).toBe(true);
}
});
test('a Profile handler refuses an Event and accepts a Profile', async () => {
const pod = body => reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body } });
const asProfile = { activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Profile' };
expect((await verifyEmittedActivity({ ...asProfile, read: pod(event()) })).ok).toBe(false);
expect((await verifyEmittedActivity({ ...asProfile, read: pod({ id: OBJ, type: ['as:Profile', 'vcard:Individual'] }) })).ok).toBe(true);
});
test('a type is required for anything but a Delete (a forgotten check is a programming error)', async () => {
await expect(verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', read: reader({}) })).rejects.toThrow('objectType is required');
});
test('the pod answering with another resource than the activity asked for is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { ...createActivity, id: `${ALICE}/outbox/other` } }, [OBJ]: { status: 200, body: event() } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('another resource');
});
});
describe('verifyActivity — an activity RECEIVED in an inbox (emitter = the actor whose pod holds it)', () => {
// Bob joins Alice's meeting point: the Join lies in Bob's pod, its object in Alice's pod; the
// notification comes from ALICE's inbox (Alice is the box owner, `actorUri`).
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE };
const meetingPoint = { id: MP, type: 'Event', 'apods:attendees': `${ALICE}/data/collections/mp1-attendees` };
const received = read => verifyActivity({ activity: JOIN, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
test('the normal case: a Join stored in the joiner\'s pod, about the inbox owner\'s meeting point', async () => {
const r = await received(reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(true);
expect(r.emitterUri).toBe(BOB);
expect(r.object).toEqual(meetingPoint);
});
test('an activity naming an actor whose pod does not hold it is refused (Mallory cannot join as Bob)', async () => {
const MALLORY = 'http://localhost:3000/mallory';
const forgedUri = `${MALLORY}/data/activities/join-as-bob`;
const read = reader({ [forgedUri]: { status: 200, body: { ...join, id: forgedUri, actor: BOB } }, [MP]: { status: 200, body: meetingPoint } });
const r = await verifyActivity({ activity: forgedUri, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not in the pod of the actor it names');
});
test('a Join about an object outside the inbox owner\'s pod is refused (an embedded attendees URI never reaches a write)', async () => {
const elsewhere = `${BOB}/data/events/bobs-mp`;
const read = reader({ [JOIN]: { status: 200, body: { ...join, object: { id: elsewhere, type: 'Event', 'apods:attendees': 'http://localhost:3000/carol/data/c' } } } });
const r = await received(read);
expect(r.ok).toBe(false);
expect(r.reason).toContain(`not in the pod of ${ALICE}`);
});
test('an activity with several actors, or a Leave where a Join is expected, is refused', async () => {
let r = await received(reader({ [JOIN]: { status: 200, body: { ...join, actor: [BOB, ALICE] } }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(false);
r = await received(reader({ [JOIN]: { status: 200, body: { ...join, type: 'Leave' } }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(false);
});
test('a location rule must be stated: ANY_POD explicitly, never by omission', async () => {
await expect(verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectType: 'Event', read: reader({}) })).rejects.toThrow('objectOwnerUri is required');
const read = reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } });
const r = await verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectOwnerUri: ANY_POD, objectType: 'Event', read });
expect(r.ok).toBe(true);
});
});
describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
const pod = { [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: { id: MP, type: 'Event' } } };
const service = () => ({ name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error(m) { this.e = m; } } });
const ctx = { call: async (action, { resourceUri }) => pod[resourceUri] || { status: 404 } };
test('an inbox Join runs the handler with the emitter read from the pod, not from the webhook body', async () => {
const ran = [];
const onReceive = verifiedReceive('Join', async (_ctx, verdict, actorUri) => ran.push([verdict.emitterUri, verdict.objectUri, actorUri]), { objectType: 'Event' }).bind(service());
// The webhook body claims Carol joined; the stored activity says Bob.
await onReceive(ctx, { id: JOIN, type: 'Join', actor: 'http://localhost:3000/carol', object: MP }, ALICE);
expect(ran).toEqual([[BOB, MP, ALICE]]);
});
test('an outbox Join about an event in another pod passes with objectIn "any", and is refused by default', async () => {
const ran = [];
const handle = async (_ctx, verdict) => ran.push(verdict.objectUri);
await verifiedEmit('Join', handle, { objectType: 'Event', objectIn: 'any' }).call(service(), ctx, JOIN, BOB);
const svc = service();
await verifiedEmit('Join', handle, { objectType: 'Event' }).call(svc, ctx, JOIN, BOB);
expect(ran).toEqual([MP]);
expect(svc.logger.w).toContain(`not in the pod of ${BOB}`);
});
});
describe('verifiedEmit — a Join from a participant\'s outbox', () => {
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/outbox/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
test('the activity AND the meeting point are read through the outbox owner\'s access, the stored object handed over', async () => {
const reads = [];
const stored = { [JOIN]: join, [MP]: { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` } };
const ctx = {
call: async (action, params) => {
reads.push([action, params.resourceUri, params.actorUri]);
const body = stored[params.resourceUri];
return body ? { status: 200, body } : { status: 404 };
}
};
const ran = [];
const service = { name: 'test', logger: { info() {}, warn() {}, error() {} } };
// The webhook body claims another object: only the stored one counts.
await verifiedEmit('Join', async (_ctx, verdict) => ran.push(verdict.object), { objectType: 'Event', objectIn: 'any' })
.call(service, ctx, { ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } }, BOB);
expect(reads).toEqual([['pod-resources.get', JOIN, BOB], ['pod-resources.get', MP, BOB]]);
expect(ran).toEqual([stored[MP]]);
});
test('a Join whose stored actor is not the outbox owner is refused', async () => {
const ctx = { call: async (_action, { resourceUri }) => (resourceUri === JOIN ? { status: 200, body: { ...join, actor: ALICE } } : { status: 200, body: { id: MP, type: 'Event' } }) };
const ran = [];
const service = { name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error() {} } };
await verifiedEmit('Join', async () => ran.push(true), { objectType: 'Event', objectIn: 'any' }).call(service, ctx, join, BOB);
expect(ran).toEqual([]);
expect(service.logger.w).toContain('not attributed to');
});
});
describe("verifiedDelivery — a Join delivered to the app's inbox: the body's actor is a claim", () => {
const APP = 'http://localhost:3005/as/actor/app';
const MALLORY = 'http://localhost:3000/mallory';
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: APP };
const meetingPoint = { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` };
/** A pod answering `stored` (uri → body), recording every read as [uri, actorUri]. */
function setup(stored = { [JOIN]: join, [MP]: meetingPoint }) {
const reads = [];
const ctx = {
call: async (_action, { resourceUri, actorUri }) => {
reads.push([resourceUri, actorUri]);
const body = stored[resourceUri];
return body ? { status: 200, body } : { status: 404 };
}
};
const logs = { warn: [], error: [] };
const service = { name: 'test', logger: { info() {}, warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
const ran = [];
const deliver = (body, options = { objectType: 'Event', objectIn: 'any' }, handle = async (_ctx, verdict, emitterUri) => ran.push([emitterUri, verdict.object])) =>
verifiedDelivery('Join', handle, options).call(service, ctx, body, APP);
return { reads, logs, ran, deliver };
}
test("the normal case: the claimed actor's pod holds the activity naming them — the handler runs for them, on the STORED object, everything read through their pod", async () => {
const w = setup();
await w.deliver({ ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } });
expect(w.ran).toEqual([[BOB, meetingPoint]]);
expect(w.reads).toEqual([[JOIN, BOB], [MP, BOB]]);
expect(w.logs.warn).toEqual([]);
});
test("a body claiming another actor for an activity of Bob's pod is refused before anything is read", async () => {
const w = setup();
await w.deliver({ ...join, actor: MALLORY });
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${MALLORY}`))).toBe(true);
});
test("an activity held in Mallory's pod claiming Bob as its actor is refused before anything is read", async () => {
const forged = `${MALLORY}/data/activities/join-1`;
const w = setup({ [forged]: { ...join, id: forged }, [MP]: meetingPoint });
await w.deliver({ ...join, id: forged });
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
});
test("an activity of Bob's pod whose stored copy names someone else is refused", async () => {
const w = setup({ [JOIN]: { ...join, actor: ALICE }, [MP]: meetingPoint });
await w.deliver(join);
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes('not attributed to'))).toBe(true);
});
test('a body naming no single actor, or no body at all, is refused without a read', async () => {
for (const body of [{ ...join, actor: undefined }, { ...join, actor: [BOB, ALICE] }, JOIN, undefined]) {
const w = setup();
await w.deliver(body);
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes('names no single actor'))).toBe(true);
}
});
test("an activity the claimed actor's pod does not hold is refused", async () => {
const w = setup({ [MP]: meetingPoint });
await w.deliver(join);
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes('does not return the activity'))).toBe(true);
});
test('by default the object must lie in the emitter\'s pod; objectIn "any" lets the handler check it', async () => {
const w = setup();
await w.deliver(join, { objectType: 'Event' });
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
});
test('a failing handler or read is logged, never thrown', async () => {
const w = setup();
await w.deliver(join, { objectType: 'Event', objectIn: 'any' }, async () => {
throw new Error('boom');
});
expect(w.logs.error.some(m => m.includes('boom'))).toBe(true);
});
});