Scrum4Me/__tests__/api/security.test.ts
Janpeter Visser 6cd98129f2
M14: TaskDialog (create/edit) + story auto-promotion (#21)
* chore(ST-1112): add deps for task dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add shared zod schema for task dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add missing MD3 tokens for task dialog

outline-variant, on-error-container, status-review (light + dark)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add saveTask and deleteTask server actions for TaskDialog

Unified create/edit action (saveTask) replaces separate formData-based
actions for the new TaskDialog. Uses shared zod schema, structured
SaveTaskResult union type, and context-aware revalidatePath for both
sprint and backlog routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add TaskDialog component (create & edit mode)

Builds the full TaskDialog on top of the existing @base-ui/react
Dialog primitive. Covers create mode, edit mode (status field +
created_at metadata + delete), dirty-check AlertDialog, delete
confirm AlertDialog, Cmd+Enter submit, and per-field char counters.
Uses react-hook-form + zodResolver against the shared taskSchema.
Priority and status are extracted to PrioritySegmented and
StatusSelect sub-components using MD3 tokens throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): refactor task-list to open TaskDialog via URL params

Replaces inline create/edit forms with router.push navigation:
- Clicking a task row → ?editTask=<id>
- "+ Taak" button → ?newTask=1&storyId=<storyId>
Removes CreateTaskForm, EditSubmitButton, updateTaskAction, and
createTaskAction from the component. Status toggle and DnD remain
unchanged. Rows now have cursor-pointer and keyboard a11y.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): wire TaskDialog into sprint page via searchParams

Sprint page now reads ?newTask, ?storyId, and ?editTask query params.
For edit mode: fetches the task server-side with productAccessFilter
scope (invalid/foreign IDs redirect to closePath). Renders TaskDialog
when either param is present. closePath is the sprint route without
query params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add Suspense skeleton for edit-mode task loading

Extracts task fetch into EditTaskLoader (async server component) so
the sprint board renders immediately while the task loads.
TaskDialogSkeleton shows 3 grey bars during the fetch. Invalid or
out-of-scope task IDs redirect to closePath.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): render description as markdown in task-detail-dialog

Solo task detail now renders description via react-markdown +
remark-gfm with prose styling. Sanitizes script/iframe elements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ST-1112): add saveTask/deleteTask server action tests

Covers all three demo-policy layers and cross-tenant scope:
demo blocked (403), unauthenticated blocked, validation 422,
edit cross-tenant forbidden, create cross-tenant forbidden,
and happy-path for both edit and create.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add updateTaskStatusWithStoryPromotion helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): wire story-promotion into saveTask and PATCH /api/tasks/:id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(ST-1112): add task-dialog doc and architecture note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: extend allowed tools in settings.local.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1113): add 200ms animation-delay to TaskDialogSkeleton to prevent flicker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1114): add DirtyCloseGuard reusable component for dirty-form close confirmation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1114): add shared Markdown wrapper, apply to task-detail and story-dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: allow grep -E pattern in settings.local.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 16:55:20 +02:00

464 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
product: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
sprint: {
findFirst: vi.fn(),
},
story: {
findFirst: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
task: {
findFirst: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
},
storyLog: {
create: vi.fn(),
},
todo: {
create: vi.fn(),
},
$transaction: vi.fn(),
},
}))
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 { 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<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
sprint: { findFirst: ReturnType<typeof vi.fn> }
story: {
findFirst: ReturnType<typeof vi.fn>
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
task: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
}
storyLog: { create: ReturnType<typeof vi.fn> }
todo: { create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
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' },
})
}
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()
// Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes.
mockPrisma.$transaction.mockImplementation(async (run: unknown) => {
if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise<unknown>)(prisma)
return run
})
})
// ─── 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)
})
// 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 res = await getProducts(makeGet('http://localhost/api/products'))
const data = await res.json()
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' } } },
]),
}),
})
)
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',
title: 'Task',
status: 'DONE',
story_id: 'story-1',
implementation_plan: null,
})
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ 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',
}),
})
)
})
})