const { test, expect, describe } = require('bun:test'); const { PodCollectionsHandlerMixin } = require('@activitypods/app'); const attendees = require('./attendees.service'); const contacts = require('./contacts.service'); const ALICE = 'http://localhost:3000/alice'; const MALLORY = 'http://localhost:3000/mallory'; const E = `${ALICE}/data/events/e1`; const C = `${ALICE}/data/e1-attendees`; const PROFILE = `${ALICE}/data/profiles/me`; const CREATE = `${ALICE}/data/activities/create-1`; /** A schema bound like moleculer binds it: mixin methods first, the service's own over them. */ function bind(schema, pod, { attached, groupFails = false } = {}) { const logs = { info: [], warn: [], error: [] }; const calls = []; const ctx = { call: async (action, params) => { calls.push({ action, params }); if (action === 'pod-resources.get') { const body = pod[params.resourceUri]; return body ? { ok: true, status: 200, body } : { ok: false, status: 404 }; } if (action === 'pod-collections.createAndAttach') return attached; if (action === 'pod-wac-groups.create' && groupFails) throw new Error('group creation failed'); return undefined; } }; const svc = { name: schema.name, settings: { ...PodCollectionsHandlerMixin.settings, ...schema.settings }, logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) }, actions: {} }; for (const [name, fn] of Object.entries({ ...PodCollectionsHandlerMixin.methods, ...(schema.methods || {}) })) svc[name] = fn.bind(svc); const actions = { ...PodCollectionsHandlerMixin.actions, ...schema.actions }; for (const [name, def] of Object.entries(actions)) { svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call }); } const emit = (key, hint, actorUri) => schema.activities[key].onEmit.call(svc, ctx, hint, actorUri); return { svc, ctx, calls, logs, emit }; } const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object: objectUri }); const actions = calls => calls.map(c => c.action); describe('attendees — a verified Create attaches the collection, then aligns its public read with the event', () => { test('a public event: collection attached, group created, then public-read.reconcile on the event', async () => { const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', to: 'as:Public' } }, { attached: C }); await emit('create', created(E), ALICE); expect(actions(calls).filter(a => a !== 'pod-resources.get')).toEqual([ 'pod-collections.createAndAttach', 'public-read.recordCreated', 'pod-wac-groups.create', 'public-read.reconcile' ]); expect(calls.find(c => c.action === 'public-read.recordCreated').params).toEqual({ resourceUri: C, actorUri: ALICE }); expect(calls.find(c => c.action === 'public-read.reconcile').params).toEqual({ resourceUri: E, actorUri: ALICE }); }); test('the collection is recorded as Festipod\'s even when the WAC group creation after the attach fails', async () => { const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, groupFails: true }); await emit('create', created(E), ALICE); const order = actions(calls).filter(a => a !== 'pod-resources.get'); expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'pod-wac-groups.create', 'public-read.reconcile']); expect(logs.error.some(m => m.includes('group creation failed'))).toBe(true); }); test('a PRIVATE event: its collection is reconciled too (the pod made it world-readable; it must not stay so)', async () => { const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C }); await emit('create', created(E), ALICE); expect(calls.some(c => c.action === 'public-read.reconcile' && c.params.resourceUri === E)).toBe(true); expect(calls.some(c => c.action === 'public-read.grant')).toBe(false); }); test('nothing attached (already carries a collection): no WAC group, no throw, still reconciled', async () => { const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', 'apods:attendees': C } }, { attached: undefined }); await emit('create', created(E), ALICE); expect(actions(calls)).not.toContain('pod-wac-groups.create'); expect(actions(calls)).not.toContain('public-read.recordCreated'); // not Festipod's collection: never revoked expect(actions(calls)).toContain('public-read.reconcile'); expect(logs.error).toEqual([]); }); test("a forged Create naming another actor's event, or a stored Note, touches nothing", async () => { const malloryEvent = `${MALLORY}/data/events/e`; let w = bind(attendees, {}, { attached: C }); await w.emit('create', { type: 'Create', actor: ALICE, object: { id: malloryEvent, type: 'Event' } }, ALICE); expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]); w = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Note', to: 'as:Public' } }, { attached: C }); await w.emit('create', created(E), ALICE); expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]); }); }); describe('contacts — the webhook is a hint, and nothing attached creates no group', () => { const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] }; test('the normal case: the collection is attached to the STORED profile, for the outbox owner', async () => { const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` }); // The body claims Mallory created it: the outbox owner (Alice), re-read from the pod, is who counts. await emit('create', { ...created(PROFILE), actor: MALLORY }, ALICE); const attach = calls.find(c => c.action === 'pod-collections.createAndAttach'); expect(attach.params).toMatchObject({ resourceUri: PROFILE, actorUri: ALICE }); expect(actions(calls)).toContain('pod-wac-groups.create'); }); test('a Create the pod does not store attaches nothing', async () => { const { calls, emit } = bind(contacts, { [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` }); await emit('create', { type: 'Create', actor: ALICE, object: profile }, ALICE); expect(actions(calls)).not.toContain('pod-collections.createAndAttach'); }); test('a stored object that is not a Profile attaches nothing', async () => { const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: { id: PROFILE, type: 'Event' } }, { attached: `${ALICE}/data/contacts` }); await emit('create', created(PROFILE), ALICE); expect(actions(calls)).not.toContain('pod-collections.createAndAttach'); }); test('nothing attached (the profile already carries contacts): no WAC group, and nothing thrown', async () => { const { calls, emit, logs } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: undefined }); await expect(emit('create', created(PROFILE), ALICE)).resolves.toBeUndefined(); expect(actions(calls)).toContain('pod-collections.createAndAttach'); expect(actions(calls)).not.toContain('pod-wac-groups.create'); expect(logs.error).toEqual([]); }); });