feat: ST-601-ST-612 M6 polish, beveiliging en launch-ready
- 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>
This commit is contained in:
parent
8bb8754d01
commit
d11b114fc1
27 changed files with 1858 additions and 67 deletions
153
__tests__/api/security.test.ts
Normal file
153
__tests__/api/security.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
146
__tests__/lars-flow-checklist.md
Normal file
146
__tests__/lars-flow-checklist.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# ST-612 — Lars-flow acceptatietest
|
||||
|
||||
Handmatige checklist voor de volledige Lars-flow. Doorloop alle stappen zonder handleiding.
|
||||
|
||||
## Voorbereiding
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
npx prisma db seed
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Navigeer naar [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
---
|
||||
|
||||
## Stap 1: Registreren en inloggen
|
||||
|
||||
- [ ] Registreer een nieuw account (gebruikersnaam + wachtwoord ≥ 8 tekens)
|
||||
- [ ] Je wordt automatisch doorgestuurd naar het dashboard
|
||||
- [ ] Log uit en log opnieuw in — sessie werkt correct
|
||||
|
||||
---
|
||||
|
||||
## Stap 2: Product aanmaken
|
||||
|
||||
- [ ] Klik op "+ Nieuw product"
|
||||
- [ ] Vul naam, beschrijving, repo URL en Definition of Done in
|
||||
- [ ] Sla op — je wordt doorgestuurd naar de Product Backlog pagina
|
||||
|
||||
---
|
||||
|
||||
## Stap 3: Product Backlog opbouwen
|
||||
|
||||
- [ ] Klik op "+ PBI" en maak minimaal 3 PBI's aan met verschillende prioriteiten
|
||||
- [ ] Sleep een PBI naar een andere positie — volgorde blijft behouden na page refresh
|
||||
- [ ] Sleep een PBI naar een andere prioriteitsgroep — prioriteit verandert
|
||||
- [ ] Selecteer een PBI — het rechterpaneel toont "Nog geen stories"
|
||||
- [ ] Maak minimaal 2 stories aan voor de geselecteerde PBI
|
||||
- [ ] Klik op een story-blok — een slide-over opent met bewerkingsformulier
|
||||
- [ ] Pas de titel en acceptatiecriteria aan en sla op
|
||||
- [ ] Sluit de slide-over (Escape of klik buiten)
|
||||
|
||||
---
|
||||
|
||||
## Stap 4: Sprint aanmaken en plannen
|
||||
|
||||
- [ ] Navigeer naar de Sprint Backlog (knop in de header)
|
||||
- [ ] Klik op "Sprint starten" en voer een Sprint Goal in
|
||||
- [ ] In het rechterpaneel zijn de PBI's en stories zichtbaar
|
||||
- [ ] Klik op een story om deze toe te voegen aan de Sprint
|
||||
- [ ] Voeg minimaal 2 stories toe
|
||||
- [ ] Sleep stories in het linkerpaneel om de volgorde te bepalen
|
||||
- [ ] Navigeer naar Sprint Planning
|
||||
- [ ] Selecteer een story in de linkerkolom — taken verschijnen rechts
|
||||
- [ ] Maak minimaal 2 taken aan voor de story
|
||||
- [ ] Verander de taakstatus door op de badge te klikken (To Do → In Progress → Done)
|
||||
|
||||
---
|
||||
|
||||
## Stap 5: API token aanmaken
|
||||
|
||||
- [ ] Ga naar Instellingen → API Tokens
|
||||
- [ ] Klik op "+ Token aanmaken" en geef het een naam
|
||||
- [ ] Kopieer het token (wordt slechts eenmalig getoond)
|
||||
- [ ] Sla het token tijdelijk op voor de curl-tests
|
||||
|
||||
---
|
||||
|
||||
## Stap 6: API-endpoints testen via curl
|
||||
|
||||
Vervang `<token>`, `<product-id>` en `<story-id>` met echte waarden.
|
||||
|
||||
```bash
|
||||
# 1. Producten ophalen
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://localhost:3000/api/products
|
||||
# Verwacht: JSON-array met het aangemaakte product
|
||||
|
||||
# 2. Volgende story ophalen
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://localhost:3000/api/products/<product-id>/next-story
|
||||
# Verwacht: story-object met taken
|
||||
|
||||
# 3. Implementatieplan loggen
|
||||
curl -X POST -H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"IMPLEMENTATION_PLAN","content":"Aanpak: component-first, dan server action"}' \
|
||||
http://localhost:3000/api/stories/<story-id>/log
|
||||
# Verwacht: {"id":"...","type":"IMPLEMENTATION_PLAN"}
|
||||
|
||||
# 4. Testresultaat loggen
|
||||
curl -X POST -H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"TEST_RESULT","content":"Alle unit tests geslaagd","status":"PASSED"}' \
|
||||
http://localhost:3000/api/stories/<story-id>/log
|
||||
# Verwacht: {"id":"...","type":"TEST_RESULT"}
|
||||
|
||||
# 5. Commit loggen
|
||||
curl -X POST -H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"COMMIT","content":"feat: story afgerond","commit_hash":"abc1234","commit_message":"feat: ST-XXX story afgerond"}' \
|
||||
http://localhost:3000/api/stories/<story-id>/log
|
||||
# Verwacht: {"id":"...","type":"COMMIT"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 7: Activiteitenlog controleren in de UI
|
||||
|
||||
- [ ] Open de story in de Product Backlog (klik op het story-blok)
|
||||
- [ ] Scroll naar het activiteitenlog onderaan de slide-over
|
||||
- [ ] Verifieer dat het implementatieplan (blauw), testresultaat (groen) en commit (paars) zichtbaar zijn
|
||||
|
||||
---
|
||||
|
||||
## Stap 8: Sprint afronden
|
||||
|
||||
- [ ] Navigeer naar de Sprint Backlog
|
||||
- [ ] Klik op "Sprint afronden"
|
||||
- [ ] Besluit per story: Done of Terugplaatsen
|
||||
- [ ] Klik "Sprint afronden" — sprint wordt afgesloten
|
||||
|
||||
---
|
||||
|
||||
## Stap 9: Demo-gebruiker controleren
|
||||
|
||||
- [ ] Log uit
|
||||
- [ ] Log in als `demo` / `demo1234`
|
||||
- [ ] Verifieer dat geen enkel formulier of knop schrijfrechten heeft
|
||||
- [ ] Tooltip "Niet beschikbaar in demo-modus" zichtbaar bij hover op uitgeschakelde knoppen
|
||||
|
||||
---
|
||||
|
||||
## Stap 10: Responsive / kleine schermen
|
||||
|
||||
- [ ] Verklein het browservenster tot < 1024px breed
|
||||
- [ ] Banner "Scrum4Me is ontworpen voor schermen van minimaal 1024px" verschijnt
|
||||
|
||||
---
|
||||
|
||||
## Resultaat
|
||||
|
||||
- [ ] Alle stappen doorlopen zonder fouten
|
||||
- [ ] Alle API-responses zijn correct JSON
|
||||
- [ ] Geen console-errors of crashes
|
||||
Loading…
Add table
Add a link
Reference in a new issue