From ead91cef5f6ac85fe15f11a11cb2d054c01efbaf Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 25 Apr 2026 18:26:54 +0200 Subject: [PATCH] test(security): extend security.test.ts to cover all 7 API endpoints Adds 401, 403 demo-block, and cross-user isolation tests for: GET /api/products/:id/next-story, GET /api/sprints/:id/tasks, PATCH /api/stories/:id/tasks/reorder, POST /api/stories/:id/log, POST /api/todos. Expands prisma mock to cover all required models. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/api/security.test.ts | 521 +++++++++++++++++++++++++-------- 1 file changed, 401 insertions(+), 120 deletions(-) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index e73d564..54d1e4b 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -1,22 +1,31 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -// Mock prisma vi.mock('@/lib/prisma', () => ({ prisma: { product: { findMany: vi.fn(), + findFirst: vi.fn(), + }, + sprint: { + findFirst: vi.fn(), + }, + story: { + findFirst: vi.fn(), }, task: { findFirst: vi.fn(), update: vi.fn(), }, - apiToken: { - findUnique: vi.fn(), + storyLog: { + create: vi.fn(), }, + todo: { + create: vi.fn(), + }, + $transaction: vi.fn(), }, })) -// Mock api-auth to control which user is "authenticated" vi.mock('@/lib/api-auth', () => ({ authenticateApiRequest: vi.fn(), })) @@ -24,136 +33,408 @@ vi.mock('@/lib/api-auth', () => ({ import { prisma } from '@/lib/prisma' import { authenticateApiRequest } from '@/lib/api-auth' import { GET as getProducts } from '@/app/api/products/route' +import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route' +import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route' +import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' +import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' +import { POST as postTodo } from '@/app/api/todos/route' const mockPrisma = prisma as unknown as { - product: { findMany: ReturnType } + product: { findMany: ReturnType; findFirst: ReturnType } + sprint: { findFirst: ReturnType } + story: { findFirst: ReturnType } task: { findFirst: ReturnType; update: ReturnType } + storyLog: { create: ReturnType } + todo: { create: ReturnType } + $transaction: ReturnType } const mockAuth = authenticateApiRequest as ReturnType -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, +const UNAUTHORIZED = { error: 'Unauthorized', status: 401 } +const DEMO_AUTH = { userId: 'demo-user', isDemo: true } +const USER_1_AUTH = { userId: 'user-1', isDemo: false } +const USER_2_AUTH = { userId: 'user-2', isDemo: false } + +function makeGet(url: string): Request { + return new Request(url, { + method: 'GET', + headers: { Authorization: 'Bearer test-token' }, }) } -describe('Security: cross-user access', () => { - beforeEach(() => { - vi.clearAllMocks() +function makePost(url: string, body: unknown): Request { + return new Request(url, { + method: 'POST', + headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +function makePatch(url: string, body: unknown): Request { + return new Request(url, { + method: 'PATCH', + headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +function routeCtx(id: string) { + return { params: Promise.resolve({ id }) } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +// ─── GET /api/products ──────────────────────────────────────────────────────── + +describe('GET /api/products', () => { + // TC-P-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await getProducts(makeGet('http://localhost/api/products')) + expect(res.status).toBe(401) }) - 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 }, - ]) + // TC-P-08 + it('returns only the authenticated user\'s products (cross-user isolation)', async () => { + mockAuth.mockResolvedValue(USER_1_AUTH) + mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1', name: 'Product A', repo_url: null }]) - const response = await getProducts(makeRequest()) - const data = await response.json() + const res = await getProducts(makeGet('http://localhost/api/products')) + const data = await res.json() - expect(response.status).toBe(200) - expect(data).toHaveLength(1) - // Verify the query includes owned products and products shared through membership. - expect(mockPrisma.product.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - archived: false, - OR: expect.arrayContaining([ - { user_id: 'user-1' }, - { members: { some: { 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! - }, - }, + expect(res.status).toBe(200) + expect(mockPrisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + archived: false, + OR: expect.arrayContaining([ + { user_id: 'user-1' }, + { members: { some: { user_id: 'user-1' } } }, + ]), + }), }) - - 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) - }) + ) + expect(data).toHaveLength(1) + }) +}) + +// ─── GET /api/products/:id/next-story ──────────────────────────────────────── + +describe('GET /api/products/:id/next-story', () => { + // TC-NS-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await getNextStory( + makeGet('http://localhost/api/products/prod-1/next-story'), + routeCtx('prod-1') + ) + expect(res.status).toBe(401) + }) + + // TC-NS-03 / TC-NS-07: product not accessible covers both "not found" and cross-user + it('returns 404 when product is not accessible to the authenticated user', async () => { + mockAuth.mockResolvedValue(USER_1_AUTH) + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const res = await getNextStory( + makeGet('http://localhost/api/products/prod-other/next-story'), + routeCtx('prod-other') + ) + expect(res.status).toBe(404) + expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + product_id: 'prod-other', + status: 'ACTIVE', + product: expect.objectContaining({ + OR: expect.arrayContaining([{ user_id: 'user-1' }]), + }), + }), + }) + ) + }) + + // TC-NS-07 explicit cross-user + it('returns 404 for another user\'s product', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const res = await getNextStory( + makeGet('http://localhost/api/products/prod-1/next-story'), + routeCtx('prod-1') + ) + expect(res.status).toBe(404) + expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + product: expect.objectContaining({ + OR: expect.arrayContaining([{ user_id: 'user-2' }]), + }), + }), + }) + ) + }) +}) + +// ─── GET /api/sprints/:id/tasks ─────────────────────────────────────────────── + +describe('GET /api/sprints/:id/tasks', () => { + // TC-ST-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await getSprintTasks( + makeGet('http://localhost/api/sprints/sprint-1/tasks'), + routeCtx('sprint-1') + ) + expect(res.status).toBe(401) + }) + + // TC-ST-03 + it('returns 404 when sprint is not found', async () => { + mockAuth.mockResolvedValue(USER_1_AUTH) + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const res = await getSprintTasks( + makeGet('http://localhost/api/sprints/nonexistent/tasks'), + routeCtx('nonexistent') + ) + expect(res.status).toBe(404) + }) + + // TC-ST-04 + it('returns 404 for another user\'s sprint', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const res = await getSprintTasks( + makeGet('http://localhost/api/sprints/sprint-1/tasks'), + routeCtx('sprint-1') + ) + expect(res.status).toBe(404) + expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'sprint-1', + product: expect.objectContaining({ + OR: expect.arrayContaining([{ user_id: 'user-2' }]), + }), + }), + }) + ) + }) +}) + +// ─── PATCH /api/stories/:id/tasks/reorder ──────────────────────────────────── + +describe('PATCH /api/stories/:id/tasks/reorder', () => { + const VALID_BODY = { task_ids: ['task-x'] } + + // TC-RO-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(401) + }) + + // TC-RO-03 + it('returns 403 for demo users', async () => { + mockAuth.mockResolvedValue(DEMO_AUTH) + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBe('Niet beschikbaar in demo-modus') + }) + + // TC-RO-04 / TC-RO-05 + it('returns 404 when story is not accessible to the authenticated user', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.story.findFirst.mockResolvedValue(null) + + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(404) + expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'story-1', + product: expect.objectContaining({ + OR: expect.arrayContaining([{ user_id: 'user-2' }]), + }), + }), + }) + ) + }) +}) + +// ─── POST /api/stories/:id/log ──────────────────────────────────────────────── + +describe('POST /api/stories/:id/log', () => { + const VALID_BODY = { type: 'IMPLEMENTATION_PLAN', content: 'Plan: step 1' } + + // TC-L-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await postStoryLog( + makePost('http://localhost/api/stories/story-1/log', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(401) + }) + + // TC-L-03 + it('returns 403 for demo users', async () => { + mockAuth.mockResolvedValue(DEMO_AUTH) + const res = await postStoryLog( + makePost('http://localhost/api/stories/story-1/log', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBe('Niet beschikbaar in demo-modus') + }) + + // TC-L-04 / TC-L-05 + it('returns 404 when story is not accessible to the authenticated user', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.story.findFirst.mockResolvedValue(null) + + const res = await postStoryLog( + makePost('http://localhost/api/stories/story-1/log', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(404) + expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'story-1', + product: expect.objectContaining({ + OR: expect.arrayContaining([{ user_id: 'user-2' }]), + }), + }), + }) + ) + }) +}) + +// ─── PATCH /api/tasks/:id ───────────────────────────────────────────────────── + +describe('PATCH /api/tasks/:id', () => { + // TC-T-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await patchTask( + makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }), + routeCtx('task-1') + ) + expect(res.status).toBe(401) + }) + + // TC-T-03 + it('returns 403 for demo users', async () => { + mockAuth.mockResolvedValue(DEMO_AUTH) + const res = await patchTask( + makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }), + routeCtx('task-1') + ) + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBeTruthy() + }) + + // TC-T-04 + it('returns 404 when task does not exist', async () => { + mockAuth.mockResolvedValue(USER_1_AUTH) + mockPrisma.task.findFirst.mockResolvedValue(null) + + const res = await patchTask( + makePatch('http://localhost/api/tasks/nonexistent', { status: 'DONE' }), + routeCtx('nonexistent') + ) + expect(res.status).toBe(404) + }) + + // TC-T-05 + it('returns 403 when task belongs to a different user', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.task.findFirst.mockResolvedValue({ + id: 'task-1', + story: { product: { user_id: 'user-1' } }, + }) + + const res = await patchTask( + makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }), + routeCtx('task-1') + ) + expect(res.status).toBe(403) + }) + + // TC-T-08 (happy path, sanity check) + it('returns 200 when task belongs to the authenticated user', async () => { + mockAuth.mockResolvedValue(USER_1_AUTH) + mockPrisma.task.findFirst.mockResolvedValue({ + id: 'task-1', + story: { product: { user_id: 'user-1' } }, + }) + mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' }) + + const res = await patchTask( + makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }), + routeCtx('task-1') + ) + expect(res.status).toBe(200) + }) +}) + +// ─── POST /api/todos ────────────────────────────────────────────────────────── + +describe('POST /api/todos', () => { + // product_id is required by the Zod schema (z.string().min(1)) + const VALID_BODY = { title: 'Test todo', product_id: 'prod-1' } + + // TC-TD-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY)) + expect(res.status).toBe(401) + }) + + // TC-TD-03 + it('returns 403 for demo users', async () => { + mockAuth.mockResolvedValue(DEMO_AUTH) + const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY)) + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBe('Niet beschikbaar in demo-modus') + }) + + // TC-TD-08 + it('returns 404 when product_id belongs to another user', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.product.findFirst.mockResolvedValue(null) + + const res = await postTodo( + makePost('http://localhost/api/todos', { title: 'Todo', product_id: 'prod-owned-by-user-1' }) + ) + expect(res.status).toBe(404) + // Verify it queries by user_id, not productAccessFilter + expect(mockPrisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'prod-owned-by-user-1', + user_id: 'user-2', + }), + }) + ) }) })