diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 465ca1b..e73d564 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -57,10 +57,16 @@ describe('Security: cross-user access', () => { expect(response.status).toBe(200) expect(data).toHaveLength(1) - // Verify the query filtered by user_id + // Verify the query includes owned products and products shared through membership. expect(mockPrisma.product.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: expect.objectContaining({ user_id: 'user-1' }), + where: expect.objectContaining({ + archived: false, + OR: expect.arrayContaining([ + { user_id: 'user-1' }, + { members: { some: { user_id: 'user-1' } } }, + ]), + }), }) ) }) diff --git a/actions/sprints.ts b/actions/sprints.ts index 31715d4..ed8857d 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -12,6 +12,10 @@ async function getSession() { return getIronSession(await cookies(), sessionOptions) } +function hasDuplicateIds(ids: string[]) { + return new Set(ids).size !== ids.length +} + export async function createSprintAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } @@ -79,6 +83,7 @@ export async function addStoryToSprintAction(sprintId: string, storyId: string) where: { id: storyId, product: productAccessFilter(session.userId) }, }) if (!story) return { error: 'Story niet gevonden' } + if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' } const last = await prisma.story.findFirst({ where: { sprint_id: sprintId }, @@ -120,12 +125,19 @@ export async function reorderSprintStoriesAction(sprintId: string, orderedIds: s const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige Sprint Backlog-volgorde' } const sprint = await prisma.sprint.findFirst({ where: { id: sprintId, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden' } + const stories = await prisma.story.findMany({ + where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id }, + select: { id: true }, + }) + if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' } + await prisma.$transaction( orderedIds.map((id, i) => prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } }) @@ -149,8 +161,22 @@ export async function completeSprintAction( }) if (!sprint) return { error: 'Sprint niet gevonden' } + const entries = Object.entries(decisions) + if (entries.some(([, status]) => status !== 'DONE' && status !== 'OPEN')) { + return { error: 'Ongeldige Sprint-afronding' } + } + + const storyIds = entries.map(([storyId]) => storyId) + if (hasDuplicateIds(storyIds)) return { error: 'Ongeldige Sprint-afronding' } + + const stories = await prisma.story.findMany({ + where: { id: { in: storyIds }, sprint_id: sprintId, product_id: sprint.product_id }, + select: { id: true }, + }) + if (stories.length !== storyIds.length) return { error: 'Ongeldige Sprint-afronding' } + await prisma.$transaction([ - ...Object.entries(decisions).map(([storyId, status]) => + ...entries.map(([storyId, status]) => prisma.story.update({ where: { id: storyId }, data: { diff --git a/actions/stories.ts b/actions/stories.ts index e7247d2..4813695 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -19,6 +19,10 @@ async function verifyStoryAccess(storyId: string, userId: string) { }) } +function hasDuplicateIds(ids: string[]) { + return new Set(ids).size !== ids.length +} + const createStorySchema = z.object({ pbiId: z.string(), productId: z.string(), @@ -61,7 +65,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) const story = await prisma.story.create({ data: { pbi_id: parsed.data.pbiId, - product_id: parsed.data.productId, + product_id: pbi.product_id, title: parsed.data.title, priority: parsed.data.priority, sort_order, @@ -69,7 +73,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) }, }) - revalidatePath(`/products/${parsed.data.productId}`) + revalidatePath(`/products/${pbi.product_id}`) return { success: true, story } } @@ -122,10 +126,17 @@ export async function reorderPbisAction(productId: string, orderedIds: string[]) const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige PBI-volgorde' } const product = await getAccessibleProduct(productId, session.userId) if (!product) return { error: 'Product niet gevonden' } + const pbis = await prisma.pbi.findMany({ + where: { id: { in: orderedIds }, product_id: productId }, + select: { id: true }, + }) + if (pbis.length !== orderedIds.length) return { error: 'Ongeldige PBI-volgorde' } + await prisma.$transaction( orderedIds.map((id, i) => prisma.pbi.update({ where: { id }, data: { sort_order: i + 1.0 } }) @@ -136,7 +147,7 @@ export async function reorderPbisAction(productId: string, orderedIds: string[]) return { success: true } } -export async function updatePbiPriorityAction(pbiId: string, priority: number, productId: string) { +export async function updatePbiPriorityAction(pbiId: string, priority: number, _productId: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } @@ -145,9 +156,10 @@ export async function updatePbiPriorityAction(pbiId: string, priority: number, p where: { id: pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } + if (!Number.isInteger(priority) || priority < 1 || priority > 4) return { error: 'Ongeldige prioriteit' } const last = await prisma.pbi.findFirst({ - where: { product_id: productId, priority }, + where: { product_id: pbi.product_id, priority }, orderBy: { sort_order: 'desc' }, }) @@ -156,7 +168,7 @@ export async function updatePbiPriorityAction(pbiId: string, priority: number, p data: { priority, sort_order: (last?.sort_order ?? 0) + 1.0 }, }) - revalidatePath(`/products/${productId}`) + revalidatePath(`/products/${pbi.product_id}`) return { success: true } } @@ -199,12 +211,22 @@ export async function reorderStoriesAction( const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' } + if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) { + return { error: 'Ongeldige prioriteit' } + } const pbi = await prisma.pbi.findFirst({ where: { id: pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } + const stories = await prisma.story.findMany({ + where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id }, + select: { id: true }, + }) + if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' } + await prisma.$transaction( orderedIds.map((id, i) => prisma.story.update({ @@ -217,6 +239,6 @@ export async function reorderStoriesAction( ) ) - revalidatePath(`/products/${productId}`) + revalidatePath(`/products/${pbi.product_id}`) return { success: true } } diff --git a/actions/todos.ts b/actions/todos.ts index 2561d9f..ab9ea7e 100644 --- a/actions/todos.ts +++ b/actions/todos.ts @@ -6,6 +6,7 @@ import { getIronSession } from 'iron-session' import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { productAccessFilter } from '@/lib/product-access' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -81,6 +82,11 @@ export async function promoteTodoToPbiAction(_prevState: unknown, formData: Form }) if (!product) return { error: 'Product niet gevonden' } + const todo = await prisma.todo.findFirst({ + where: { id: parsed.data.todoId, user_id: session.userId, product_id: parsed.data.productId }, + }) + if (!todo) return { error: 'Todo niet gevonden' } + const last = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, priority: parsed.data.priority }, orderBy: { sort_order: 'desc' }, @@ -95,7 +101,7 @@ export async function promoteTodoToPbiAction(_prevState: unknown, formData: Form sort_order: (last?.sort_order ?? 0) + 1.0, }, }), - prisma.todo.delete({ where: { id: parsed.data.todoId } }), + prisma.todo.deleteMany({ where: { id: parsed.data.todoId, user_id: session.userId } }), ]) revalidatePath('/todos') @@ -125,10 +131,16 @@ export async function promoteTodoToStoryAction(_prevState: unknown, formData: Fo }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + const todo = await prisma.todo.findFirst({ + where: { id: parsed.data.todoId, user_id: session.userId }, + }) + if (!todo) return { error: 'Todo niet gevonden' } + const pbi = await prisma.pbi.findFirst({ - where: { id: parsed.data.pbiId, product: { user_id: session.userId } }, + where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } + if (todo.product_id !== pbi.product_id) return { error: 'Todo hoort niet bij dit product' } const last = await prisma.story.findFirst({ where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, @@ -139,18 +151,18 @@ export async function promoteTodoToStoryAction(_prevState: unknown, formData: Fo prisma.story.create({ data: { pbi_id: parsed.data.pbiId, - product_id: parsed.data.productId, + product_id: pbi.product_id, title: parsed.data.title, priority: parsed.data.priority, sort_order: (last?.sort_order ?? 0) + 1.0, status: 'OPEN', }, }), - prisma.todo.delete({ where: { id: parsed.data.todoId } }), + prisma.todo.deleteMany({ where: { id: parsed.data.todoId, user_id: session.userId } }), ]) revalidatePath('/todos') - revalidatePath(`/products/${parsed.data.productId}`) + revalidatePath(`/products/${pbi.product_id}`) return { success: true } } diff --git a/app/api/products/route.ts b/app/api/products/route.ts index fda4582..89c7c9e 100644 --- a/app/api/products/route.ts +++ b/app/api/products/route.ts @@ -1,5 +1,6 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' export async function GET(request: Request) { const auth = await authenticateApiRequest(request) @@ -8,7 +9,7 @@ export async function GET(request: Request) { } const products = await prisma.product.findMany({ - where: { user_id: auth.userId, archived: false }, + where: { archived: false, ...productAccessFilter(auth.userId) }, orderBy: { created_at: 'desc' }, select: { id: true, name: true, repo_url: true }, }) diff --git a/package-lock.json b/package-lock.json index ea58c02..183d1c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.4.0", + "sharp": "^0.34.5", "sonner": "^1.7.4", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", @@ -1567,7 +1568,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -5781,7 +5781,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -11081,7 +11080,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -11125,7 +11123,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 5ccccf6..dae3058 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.4.0", + "sharp": "^0.34.5", "sonner": "^1.7.4", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0",