// The per-Team activity feed (docs/website/TEAMS.md Part 4). // // The db layer is stubbed and an in-memory table stands in for `team_activity`, // so these are assertions about the RULES: what a module is allowed to write, // what a caller is allowed to see, and what the prune takes away. The three worth // protecting are the ones that are easy to "simplify" into a leak: // // 1. a module writes only into its OWN active Teams, named by external id; // 2. visibility defaults to `members` and is resolved from the session, never // from a request parameter; // 3. a hidden Team's feed does not answer a public caller. const { test, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const activityDb = require('../src/model/teams/teamActivity.db') const teamsDb = require('../src/model/teams/teams.db') const access = require('../src/model/teams/teamAccess.model') const settings = require('../src/model/settings/settings.model') const activity = require('../src/model/teams/teamActivity.model') let store const saved = new Map() function patch(mod, name, fn) { if (!saved.has(mod)) saved.set(mod, new Map()) if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) mod[name] = fn } function restore() { for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn saved.clear() } function stub() { store = { rows: [], nextId: 1, teams: [ { id: 1, module_id: 'uo', external_id: 'g1', slug: 'the-guild', hidden: 0, status: 'active' }, { id: 2, module_id: 'uo', external_id: 'g2', slug: 'hidden-guild', hidden: 1, status: 'active' }, ], allowed: new Set(), // userIds with member/grant access, keyed "teamId:userId" } patch(teamsDb, 'findActive', async (moduleId, externalId) => store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active')) patch(teamsDb, 'findBySlug', async (slug) => store.teams.find((t) => t.slug === slug)) patch(access, 'forumAccess', async (teamId, userId) => ({ allowed: store.allowed.has(`${teamId}:${userId}`), viaMembership: store.allowed.has(`${teamId}:${userId}`), viaGrant: false, isLeader: false, })) patch(activityDb, 'insert', async (item) => { if (item.dedupeKey && store.rows.some((r) => r.team_id === item.teamId && r.dedupe_key === item.dedupeKey)) { return false // the unique index doing its job } store.rows.push({ id: store.nextId++, team_id: item.teamId, source: item.source, kind: item.kind, summary: item.summary, visibility: item.visibility, actor_member_key: item.actorMemberKey, actor_user_id: item.actorUserId, payload: item.payload, occurred_at: new Date(item.occurredAt), dedupe_key: item.dedupeKey, }) return true }) patch(activityDb, 'page', async (teamId, visibilities, { limit, offset }) => store.rows .filter((r) => r.team_id === teamId && visibilities.includes(r.visibility)) .sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id) .slice(offset, offset + limit)) patch(activityDb, 'count', async (teamId, visibilities) => store.rows.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility)).length) patch(activityDb, 'deleteOlderThan', async (days) => { const cutoff = Date.now() - days * 86400_000 const before = store.rows.length store.rows = store.rows.filter((r) => r.occurred_at.getTime() >= cutoff) return before - store.rows.length }) patch(activityDb, 'overCap', async (cap) => { const byTeam = new Map() for (const r of store.rows) byTeam.set(r.team_id, (byTeam.get(r.team_id) || 0) + 1) return [...byTeam].filter(([, n]) => n > cap).map(([team_id, n]) => ({ team_id, n })) }) patch(activityDb, 'trimToCap', async (teamId, cap) => { const mine = store.rows .filter((r) => r.team_id === teamId) .sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id) const keep = new Set(mine.slice(0, cap).map((r) => r.id)) const before = store.rows.length store.rows = store.rows.filter((r) => r.team_id !== teamId || keep.has(r.id)) return before - store.rows.length }) patch(settings, 'get', async () => null) // defaults } const item = (extra = {}) => ({ externalId: 'g1', kind: 'uo.thing', summary: 'A thing happened', ...extra }) beforeEach(stub) afterEach(restore) // ── What a module may write ──────────────────────────────────────────────── test('a pushed item lands against the team its external id names', async () => { const stored = await activity.push('uo', [item()]) assert.equal(stored, 1) assert.equal(store.rows[0].team_id, 1) assert.equal(store.rows[0].source, 'uo') }) test('a module cannot write into another module\'s team', async () => { // 'other' owns no team with external id g1, so there is nothing to resolve — // and no integer the module could have sent instead, which is the point of // naming Teams by external id on this path. const stored = await activity.push('other', [item()]) assert.equal(stored, 0) assert.equal(store.rows.length, 0) }) test('an unknown external id is dropped rather than raised', async () => { const stored = await activity.push('uo', [item({ externalId: 'nope' })]) assert.equal(stored, 0) }) test('visibility defaults to members, and an unknown value does not widen it', async () => { await activity.push('uo', [item(), item({ visibility: 'everyone' }), item({ visibility: 'public' })]) assert.deepEqual(store.rows.map((r) => r.visibility), ['members', 'members', 'public']) }) test('an item with no kind or no summary is dropped, and the rest of the batch still lands', async () => { const stored = await activity.push('uo', [item({ kind: '' }), item({ summary: ' ' }), item()]) assert.equal(stored, 1) assert.equal(store.rows.length, 1) }) test('an over-long summary is truncated rather than losing the event', async () => { await activity.push('uo', [item({ summary: 'x'.repeat(400) })]) assert.equal(store.rows[0].summary.length, 255) }) test('a replayed batch with dedupe keys stores each item once', async () => { const batch = [item({ dedupeKey: 'champ:77' }), item({ dedupeKey: 'champ:78' })] await activity.push('uo', batch) await activity.push('uo', batch) // the sidecar reconnect backfill assert.equal(store.rows.length, 2) }) test('items without a dedupe key are never collapsed into each other', async () => { await activity.push('uo', [item(), item()]) assert.equal(store.rows.length, 2) }) test('a push is never rejected for being malformed at the top level', async () => { assert.equal(await activity.push('uo', null), 0) assert.equal(await activity.push('uo', []), 0) }) // ── What a caller may see ────────────────────────────────────────────────── test('an anonymous caller gets public items only, and is told the scope', async () => { await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })]) const feed = await activity.feedFor('the-guild', null) assert.equal(feed.items.length, 1) assert.equal(feed.items[0].visibility, 'public') assert.equal(feed.scope, 'public') // `total` is the caller's total, not the table's — otherwise paging lies. assert.equal(feed.total, 1) }) test('a member sees both, via the same resolver the forum uses', async () => { await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })]) store.allowed.add('1:7') const feed = await activity.feedFor('the-guild', 7) assert.equal(feed.items.length, 2) assert.equal(feed.scope, 'members') }) test('an authenticated non-member is exactly an anonymous caller here', async () => { await activity.push('uo', [item({ visibility: 'members' })]) const feed = await activity.feedFor('the-guild', 99) assert.equal(feed.items.length, 0) assert.equal(feed.scope, 'public') }) test('a hidden team\'s feed does not answer the public, but does answer its members', async () => { await activity.push('uo', [item({ externalId: 'g2', visibility: 'public' })]) assert.equal(await activity.feedFor('hidden-guild', null), null) store.allowed.add('2:7') const feed = await activity.feedFor('hidden-guild', 7) assert.equal(feed.items.length, 1) }) test('an unknown slug is not found rather than empty', async () => { assert.equal(await activity.feedFor('no-such-team', null), null) }) test('the rendered item carries the payload and never the actor identifiers', async () => { await activity.push('uo', [item({ visibility: 'public', payload: { serial: '0x77' }, actorMemberKey: '0x40012ab3', actorUserId: 7, })]) const feed = await activity.feedFor('the-guild', null) assert.deepEqual(feed.items[0].payload, { serial: '0x77' }) assert.equal('actorMemberKey' in feed.items[0], false) assert.equal('actorUserId' in feed.items[0], false) }) // ── Core's own items ─────────────────────────────────────────────────────── test('core writes as source=core and public', async () => { await activity.logCore({ teamId: 1, kind: activity.CORE_KINDS.MEMBER_JOINED, summary: 'Aldric joined' }) assert.equal(store.rows[0].source, 'core') assert.equal(store.rows[0].visibility, 'public') }) test('core refuses an item with nothing to say', async () => { assert.equal(await activity.logCore({ teamId: 1, kind: 'core.x' }), false) assert.equal(store.rows.length, 0) }) // ── Retention ────────────────────────────────────────────────────────────── test('the prune drops rows past the age horizon', async () => { const old = Date.now() - 100 * 86400_000 await activity.push('uo', [item({ occurredAt: old }), item()]) const res = await activity.prune() assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS) assert.equal(res.byAge, 1) assert.equal(store.rows.length, 1) }) test('the prune trims a team back to the row cap, newest kept', async () => { patch(settings, 'get', async (key) => (key === activity.CAP_KEY ? '3' : null)) const base = Date.now() for (let i = 0; i < 6; i++) { // eslint-disable-next-line no-await-in-loop await activity.push('uo', [item({ summary: `event ${i}`, occurredAt: base + i * 1000 })]) } const res = await activity.prune() assert.equal(res.byCap, 3) assert.deepEqual(store.rows.map((r) => r.summary), ['event 3', 'event 4', 'event 5']) }) test('a zero or negative retention setting is rejected rather than emptying the feed', async () => { patch(settings, 'get', async (key) => (key === activity.RETAIN_KEY ? '0' : null)) await activity.push('uo', [item()]) const res = await activity.prune() assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS) assert.equal(store.rows.length, 1) }) test('an unreadable settings table leaves the defaults standing', async () => { patch(settings, 'get', async () => { throw new Error('pool down') }) const res = await activity.retentionConfig() assert.deepEqual(res, { days: activity.DEFAULT_RETAIN_DAYS, cap: activity.DEFAULT_ROW_CAP }) })