- ST-601/602: loading skeletons en error boundary - ST-603: Sonner toasts op alle CRUD-operaties - ST-604: DemoTooltip op uitgeschakelde knoppen - ST-605: KeyboardSensor dnd-kit, Escape sluit modals - ST-606: min-width banner < 1024px - ST-607: WCAG AA aria-labels en skip link - ST-608: rate limiting login (10/min) en registratie (5/uur) - ST-609: security integratietests cross-user toegang (7 tests) - ST-610: GitHub Actions CI/CD workflow - ST-611: README met quickstart, deployment en API-docs - ST-612: Lars-flow acceptatiechecklist - fix: settings toont gebruikersnaam i.p.v. interne id - fix: seed idempotent, testdata altijd gekoppeld aan demo-gebruiker Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
153 lines
4.5 KiB
TypeScript
153 lines
4.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Mock prisma
|
|
vi.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
product: {
|
|
findMany: vi.fn(),
|
|
},
|
|
task: {
|
|
findFirst: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
apiToken: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
},
|
|
}))
|
|
|
|
// Mock api-auth to control which user is "authenticated"
|
|
vi.mock('@/lib/api-auth', () => ({
|
|
authenticateApiRequest: vi.fn(),
|
|
}))
|
|
|
|
import { prisma } from '@/lib/prisma'
|
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
|
import { GET as getProducts } from '@/app/api/products/route'
|
|
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
|
|
|
const mockPrisma = prisma as unknown as {
|
|
product: { findMany: ReturnType<typeof vi.fn> }
|
|
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
|
}
|
|
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
|
|
|
function makeRequest(method = 'GET', body?: unknown): Request {
|
|
return new Request('http://localhost/api/test', {
|
|
method,
|
|
headers: { 'Authorization': 'Bearer test-token', 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
}
|
|
|
|
describe('Security: cross-user access', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('GET /api/products', () => {
|
|
it('returns only the authenticated user\'s products', async () => {
|
|
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
mockPrisma.product.findMany.mockResolvedValue([
|
|
{ id: 'prod-1', name: 'Product A', repo_url: null },
|
|
])
|
|
|
|
const response = await getProducts(makeRequest())
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data).toHaveLength(1)
|
|
// Verify the query filtered by user_id
|
|
expect(mockPrisma.product.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ user_id: 'user-1' }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('returns 401 when no valid token provided', async () => {
|
|
mockAuth.mockResolvedValue({ error: 'Unauthorized', status: 401 })
|
|
|
|
const response = await getProducts(makeRequest())
|
|
expect(response.status).toBe(401)
|
|
})
|
|
})
|
|
|
|
describe('PATCH /api/tasks/:id', () => {
|
|
it('returns 403 when task belongs to a different user', async () => {
|
|
// User 2 is authenticated but the task belongs to user 1
|
|
mockAuth.mockResolvedValue({ userId: 'user-2', isDemo: false })
|
|
mockPrisma.task.findFirst.mockResolvedValue({
|
|
id: 'task-1',
|
|
story: {
|
|
product: {
|
|
user_id: 'user-1', // different user!
|
|
},
|
|
},
|
|
})
|
|
|
|
const response = await patchTask(
|
|
makeRequest('PATCH', { status: 'DONE' }),
|
|
{ params: Promise.resolve({ id: 'task-1' }) }
|
|
)
|
|
|
|
expect(response.status).toBe(403)
|
|
const data = await response.json()
|
|
expect(data.error).toBeTruthy()
|
|
})
|
|
|
|
it('returns 403 for demo users', async () => {
|
|
mockAuth.mockResolvedValue({ userId: 'demo-user', isDemo: true })
|
|
|
|
const response = await patchTask(
|
|
makeRequest('PATCH', { status: 'DONE' }),
|
|
{ params: Promise.resolve({ id: 'task-1' }) }
|
|
)
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
it('allows update when task belongs to the authenticated user', async () => {
|
|
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
mockPrisma.task.findFirst.mockResolvedValue({
|
|
id: 'task-1',
|
|
story: {
|
|
product: {
|
|
user_id: 'user-1', // same user
|
|
},
|
|
},
|
|
})
|
|
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' })
|
|
|
|
const response = await patchTask(
|
|
makeRequest('PATCH', { status: 'DONE' }),
|
|
{ params: Promise.resolve({ id: 'task-1' }) }
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
})
|
|
|
|
it('returns 404 when task does not exist', async () => {
|
|
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
mockPrisma.task.findFirst.mockResolvedValue(null)
|
|
|
|
const response = await patchTask(
|
|
makeRequest('PATCH', { status: 'DONE' }),
|
|
{ params: Promise.resolve({ id: 'nonexistent' }) }
|
|
)
|
|
|
|
expect(response.status).toBe(404)
|
|
})
|
|
|
|
it('returns 401 when no valid token', async () => {
|
|
mockAuth.mockResolvedValue({ error: 'Unauthorized', status: 401 })
|
|
|
|
const response = await patchTask(
|
|
makeRequest('PATCH', { status: 'DONE' }),
|
|
{ params: Promise.resolve({ id: 'task-1' }) }
|
|
)
|
|
|
|
expect(response.status).toBe(401)
|
|
})
|
|
})
|
|
})
|