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); }); });