diff --git a/backend/__tests__/unit/controllers/reactionController.test.js b/backend/__tests__/unit/controllers/reactionController.test.js index 9c8f1b52..4c3469bb 100644 --- a/backend/__tests__/unit/controllers/reactionController.test.js +++ b/backend/__tests__/unit/controllers/reactionController.test.js @@ -183,6 +183,38 @@ describe('reactionController.addReaction — agent runtime path', () => { expect(MessageReaction.add).toHaveBeenCalledWith('11', 'human-1', '👀'); }); + test('human NOT in pg pod_members but IN mongo pod.members is still allowed (dual-DB drift, 2026-07-24)', async () => { + pool.query + .mockResolvedValueOnce(podLookup('pod-drift')) // loadPodIdForMessage + .mockResolvedValueOnce(memberLookup(0)); // pg pod_members MISS → must fall back to Mongo + Pod.findById.mockReturnValue({ + select: () => ({ lean: () => Promise.resolve({ members: [{ toString: () => 'human-2' }] }) }), + }); + + const req = { params: { messageId: '12' }, body: { emoji: '👍' }, user: { _id: 'human-2' } }; + const res = buildRes(); + + await reactionController.addReaction(req, res); + + expect(res.status).not.toHaveBeenCalledWith(403); + expect(MessageReaction.add).toHaveBeenCalledWith('12', 'human-2', '👍'); + }); + + test('human in neither pg pod_members nor mongo members → 403', async () => { + pool.query + .mockResolvedValueOnce(podLookup('pod-x')) + .mockResolvedValueOnce(memberLookup(0)); + Pod.findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ members: [] }) }) }); + + const req = { params: { messageId: '13' }, body: { emoji: '👍' }, user: { _id: 'stranger' } }; + const res = buildRes(); + + await reactionController.addReaction(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(MessageReaction.add).not.toHaveBeenCalled(); + }); + test('unauthenticated request (no user, no agentUser) → 401', async () => { const req = { params: { messageId: '1' }, body: { emoji: '👍' } }; const res = buildRes(); diff --git a/backend/controllers/reactionController.ts b/backend/controllers/reactionController.ts index b67d21c5..98d41e8d 100644 --- a/backend/controllers/reactionController.ts +++ b/backend/controllers/reactionController.ts @@ -63,13 +63,24 @@ async function callerHasPodAccess(podId: string, userId: string, req: AuthedReq) } return false; } + // Human path. PG pod_members is the fast check, but it is a *lazily-synced + // mirror* of Mongo pod.members — community auto-join (ensureUserInCommunityPod) + // and other join paths write Mongo only. So fall back to Mongo membership, the + // source of truth, or we 403 real members whose row never reached PG. 2026-07-24: + // HQ had 66 Mongo members but 1 in PG pod_members, so 65/66 could not react and + // the failure was silent — reaction counts appeared stuck at 1. Mirrors the + // agent path above, which already checks Mongo. // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const { pool } = require('../config/db-pg'); const result = await pool.query( 'SELECT 1 FROM pod_members WHERE pod_id = $1 AND user_id = $2 LIMIT 1', [podId, userId], ); - return (result.rowCount || 0) > 0; + if ((result.rowCount || 0) > 0) return true; + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const Pod = require('../models/Pod'); + const pod = await Pod.findById(podId).select('members').lean(); + return Boolean(pod?.members?.some((mem: any) => String(mem?.userId?.toString?.() || mem) === userId)); } async function emitReactionChange(messageId: string | number, podId: string, reactions: unknown): Promise {