Scrum4Me/__tests__/api/notifications-stream.test.ts
Madhura68 1f42de5447 test(ST-1106): add cross-product access-isolation test for notifications SSE
Demo-policy + assignee-emphase zaten al in eerdere stories:
- answerQuestion demo-blok in actions/questions.test.ts (ST-1103)
- AnswerModal demo-tooltip in components/notifications/answer-modal.tsx (ST-1105)
- requireWriteAccess in MCP write-tools (ST-1102)

Deze story voegt expliciet een access-isolation-test toe op de notifications-
SSE-route: productAccessFilter wordt met de echte userId aangeroepen, en
prisma.product.findMany filter't op archived=false + user_id-scope. Dat
garandeert dat een gebruiker geen question-events ontvangt voor producten waar
hij geen membership op heeft.

Story-assignee-emphase blijft visueel-only (NotificationsBell ring-accent +
Sheet primary-container) — toegang werkt product-membership-breed zodat een
team-lid kan invallen als de assignee niet beschikbaar is.

Quality gates: lint 0 errors, tsc clean, vitest 147/147 (was 146).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:47:38 +02:00

84 lines
3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findMany: vi.fn() },
claudeQuestion: { findMany: vi.fn() },
},
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
getAccessibleProduct: vi.fn(),
}))
import { prisma } from '@/lib/prisma'
import type { NextRequest } from 'next/server'
import { GET } from '@/app/api/realtime/notifications/route'
const mockPrisma = prisma as unknown as {
product: { findMany: ReturnType<typeof vi.fn> }
claudeQuestion: { findMany: ReturnType<typeof vi.fn> }
}
function makeReq(): NextRequest {
// Minimaal NextRequest-shape voor de auth-pad — we komen niet bij de
// pg-stream-setup omdat de auth-fail vóór dat punt gebeurt.
return { signal: new AbortController().signal } as unknown as NextRequest
}
beforeEach(() => {
vi.clearAllMocks()
})
import { productAccessFilter } from '@/lib/product-access'
const mockProductAccessFilter = productAccessFilter as ReturnType<typeof vi.fn>
describe('GET /api/realtime/notifications', () => {
it('401 zonder iron-session cookie, geen DB-call', async () => {
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
const res = await GET(makeReq())
expect(res.status).toBe(401)
expect(mockPrisma.product.findMany).not.toHaveBeenCalled()
})
it('access-isolation: productAccessFilter wordt met de juiste userId aangeroepen', async () => {
// ST-1106: cross-product-isolatie zit in de productAccessFilter-Set die we
// bij connect opbouwen. We mocken de filter zodat product.findMany wel
// aangeroepen wordt maar geen producten retourneert; daarna stoppen we
// vóór de pg-stream-setup (DIRECT_URL ontbreekt → 500).
mockGetSession.mockResolvedValue({ userId: 'user-A', isDemo: false })
mockProductAccessFilter.mockReturnValue({ user_id: 'user-A' })
mockPrisma.product.findMany.mockResolvedValue([])
// We laten de stream zelf falen door DIRECT_URL/DATABASE_URL niet te zetten.
const before = { ...process.env }
delete process.env.DIRECT_URL
delete process.env.DATABASE_URL
try {
const res = await GET(makeReq())
expect(res.status).toBe(500)
} finally {
process.env.DIRECT_URL = before.DIRECT_URL
process.env.DATABASE_URL = before.DATABASE_URL
}
// De filter is met de echte userId aangeroepen (cross-user lekt niet)
expect(mockProductAccessFilter).toHaveBeenCalledWith('user-A')
expect(mockPrisma.product.findMany).toHaveBeenCalledWith({
where: { archived: false, user_id: 'user-A' },
select: { id: true },
})
})
})
// Solo-route filter (entity='question' uitgesloten) is een 1-regel-fix in
// app/api/realtime/solo/route.ts. Visueel reviewbaar in de diff; full-stream-
// regressie wordt handmatig gedekt in ST-1108-acceptatie.