Scrum4Me/__tests__/api/security.test.ts

159 lines
4.8 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 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!
},
},
})
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)
})
})
})