diff --git a/backend/src/inventory/inventory.security.spec.ts b/backend/src/inventory/inventory.security.spec.ts index 2c888cf9..05acd3e4 100644 --- a/backend/src/inventory/inventory.security.spec.ts +++ b/backend/src/inventory/inventory.security.spec.ts @@ -1,27 +1,9 @@ -import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; import { InventoryController } from './inventory.controller'; - -type MockHttpContextOptions = { - handler: Function; - clazz: Function; - user?: any; -}; - -function mockHttpContext(options: MockHttpContextOptions): ExecutionContext { - return { - getClass: () => options.clazz, - getHandler: () => options.handler, - switchToHttp: () => ({ - getRequest: () => ({ user: options.user }), - getResponse: () => ({}), - getNext: () => undefined, - }), - } as unknown as ExecutionContext; -} +import { getRolesMetadata, mockHttpContext } from '../test-utils/security-test-helpers'; describe('Inventory admin security', () => { const adminHandlers: Array<[string, Function]> = [ @@ -33,9 +15,10 @@ describe('Inventory admin security', () => { ['previewMergeAdmin', InventoryController.prototype.previewMergeAdmin], ]; - it.each(adminHandlers)('admin-endpoint %s har @Roles("admin") metadata', (_name, handler) => { - const roles = Reflect.getMetadata(ROLES_KEY, handler) as string[] | undefined; - expect(roles).toEqual(['admin']); + it('alla admin-endpoints har @Roles("admin") metadata', () => { + for (const [, handler] of adminHandlers) { + expect(getRolesMetadata(handler)).toEqual(['admin']); + } }); it.each(adminHandlers)('RolesGuard nekar icke-admin (403) på %s', (_name, handler) => { diff --git a/backend/src/meal-plan/meal-plan.idor.spec.ts b/backend/src/meal-plan/meal-plan.idor.spec.ts new file mode 100644 index 00000000..b22adc74 --- /dev/null +++ b/backend/src/meal-plan/meal-plan.idor.spec.ts @@ -0,0 +1,134 @@ +import { NotFoundException } from '@nestjs/common'; +import { MealPlanService } from './meal-plan.service'; + +describe('MealPlanService IDOR security', () => { + const fromDate = '2026-05-01'; + const toDate = '2026-05-07'; + + const prismaMock = { + mealPlanEntry: { + findMany: jest.fn(), + findUnique: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + inventoryItem: { + findMany: jest.fn(), + }, + pantryItem: { + findMany: jest.fn(), + }, + recipe: { + findUnique: jest.fn(), + }, + $transaction: jest.fn(), + }; + + const service = new MealPlanService(prismaMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockEmptyRangeData = () => { + prismaMock.mealPlanEntry.findMany.mockResolvedValue([]); + prismaMock.inventoryItem.findMany.mockResolvedValue([]); + prismaMock.pantryItem.findMany.mockResolvedValue([]); + }; + + it('scopar findByRange till userId', async () => { + mockEmptyRangeData(); + + await service.findByRange(42, fromDate, toDate); + + expect(prismaMock.mealPlanEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: 42, + date: expect.any(Object), + }), + }), + ); + }); + + it('scopar shoppingList till userId', async () => { + mockEmptyRangeData(); + + await service.shoppingList(42, fromDate, toDate); + + expect(prismaMock.mealPlanEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: 42, + }), + }), + ); + }); + + it('scopar inventoryCompare till userId', async () => { + mockEmptyRangeData(); + + await service.inventoryCompare(42, fromDate, toDate); + + expect(prismaMock.mealPlanEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: 42, + }), + }), + ); + }); + + it('upsert sätter userId på nya matplansintrag', async () => { + prismaMock.mealPlanEntry.upsert.mockResolvedValue({ + id: 1, + userId: 42, + date: new Date(), + }); + + await service.upsert(42, { date: '2026-05-11', recipeId: 1 } as any); + + expect(prismaMock.mealPlanEntry.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId_date: expect.objectContaining({ + userId: 42, + }), + }), + create: expect.objectContaining({ + userId: 42, + }), + }), + ); + }); + + it('removeByDate nekar access för annan user', async () => { + prismaMock.mealPlanEntry.findUnique.mockResolvedValue(null); + + await expect(service.removeByDate(42, '2026-05-11')).rejects.toThrow(NotFoundException); + + expect(prismaMock.mealPlanEntry.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId_date: expect.objectContaining({ + userId: 42, + }), + }), + }), + ); + }); + + it('removeByDate verifierar userId innan radering', async () => { + const entry = { id: 1, userId: 42, date: new Date() }; + prismaMock.mealPlanEntry.findUnique.mockResolvedValue(entry); + prismaMock.mealPlanEntry.delete.mockResolvedValue(entry); + + await service.removeByDate(42, '2026-05-11'); + + expect(prismaMock.mealPlanEntry.delete).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 1 }), + }), + ); + }); +}); diff --git a/backend/src/meal-plan/meal-plan.security.spec.ts b/backend/src/meal-plan/meal-plan.security.spec.ts new file mode 100644 index 00000000..f8d124bf --- /dev/null +++ b/backend/src/meal-plan/meal-plan.security.spec.ts @@ -0,0 +1,80 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { MealPlanController } from './meal-plan.controller'; + +describe('MealPlan controller security', () => { + const makeUser = (overrides: Partial<{ userId: number; role: string }> = {}) => ({ + userId: 42, + role: 'user', + ...overrides, + }); + + const mealPlanServiceMock = { + findByRange: jest.fn(), + shoppingList: jest.fn(), + inventoryCompare: jest.fn(), + upsert: jest.fn(), + removeByDate: jest.fn(), + }; + + const controller = new MealPlanController(mealPlanServiceMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('JwtAuthGuard kräver autentisering på findByRange', () => { + const guard = new JwtAuthGuard(new Reflector()); + + expect(() => guard.handleRequest(null, null, null)).toThrow(UnauthorizedException); + }); + + it('JwtAuthGuard tillåter autentiserad användare på findByRange', () => { + const guard = new JwtAuthGuard(new Reflector()); + const user = makeUser({ userId: 1 }); + + expect(guard.handleRequest(null, user, null)).toBe(user); + }); + + it('findByRange vidarebefordrar @CurrentUser.userId till service', () => { + mealPlanServiceMock.findByRange.mockResolvedValue([]); + + controller.findByRange(makeUser(), '2026-05-01', '2026-05-07'); + + expect(mealPlanServiceMock.findByRange).toHaveBeenCalledWith(42, '2026-05-01', '2026-05-07'); + }); + + it('shoppingList vidarebefordrar @CurrentUser.userId till service', () => { + mealPlanServiceMock.shoppingList.mockResolvedValue([]); + + controller.shoppingList(makeUser(), '2026-05-01', '2026-05-07'); + + expect(mealPlanServiceMock.shoppingList).toHaveBeenCalledWith(42, '2026-05-01', '2026-05-07'); + }); + + it('inventoryCompare vidarebefordrar @CurrentUser.userId till service', () => { + mealPlanServiceMock.inventoryCompare.mockResolvedValue([]); + + controller.inventoryCompare(makeUser(), '2026-05-01', '2026-05-07'); + + expect(mealPlanServiceMock.inventoryCompare).toHaveBeenCalledWith(42, '2026-05-01', '2026-05-07'); + }); + + it('upsert vidarebefordrar @CurrentUser.userId till service', () => { + const dto = { date: '2026-05-11', recipeId: 1 }; + mealPlanServiceMock.upsert.mockResolvedValue({ id: 1 }); + + controller.upsert(makeUser(), dto as any); + + expect(mealPlanServiceMock.upsert).toHaveBeenCalledWith(42, dto); + }); + + it('removeByDate vidarebefordrar @CurrentUser.userId till service', () => { + mealPlanServiceMock.removeByDate.mockResolvedValue(undefined); + + controller.removeByDate(makeUser(), '2026-05-11'); + + expect(mealPlanServiceMock.removeByDate).toHaveBeenCalledWith(42, '2026-05-11'); + }); +}); diff --git a/backend/src/pantry/pantry.idor.spec.ts b/backend/src/pantry/pantry.idor.spec.ts new file mode 100644 index 00000000..c3506ddb --- /dev/null +++ b/backend/src/pantry/pantry.idor.spec.ts @@ -0,0 +1,125 @@ +import { NotFoundException } from '@nestjs/common'; +import { PantryService } from './pantry.service'; + +describe('PantryService IDOR security', () => { + const makeCreatePantryDto = (overrides: Partial<{ productId: number; userId: number }> = {}) => ({ + productId: 1, + ...overrides, + }); + + const prismaMock = { + pantryItem: { + findMany: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + product: { + findUnique: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + inventoryItem: { + create: jest.fn(), + }, + $transaction: jest.fn(), + }; + + const service = new PantryService(prismaMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockPantryFindManyEmpty = () => { + prismaMock.pantryItem.findMany.mockResolvedValue([]); + }; + + it('scopar findAll till userId', async () => { + mockPantryFindManyEmpty(); + + await service.findAll(42); + + expect(prismaMock.pantryItem.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: 42 }), + }), + ); + }); + + it('findAllAdmin kan filtrera per userId', async () => { + mockPantryFindManyEmpty(); + + await service.findAllAdmin({ userId: 99 }); + + expect(prismaMock.pantryItem.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: 99 }), + }), + ); + }); + + it('findAllAdmin returnerar alla items om userId inte specificeras', async () => { + mockPantryFindManyEmpty(); + + await service.findAllAdmin({}); + + expect(prismaMock.pantryItem.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: {}, + }), + ); + }); + + it('create sätter userId på nya pantry-items', async () => { + prismaMock.product.findUnique.mockResolvedValue({ id: 1 }); + prismaMock.pantryItem.create.mockResolvedValue({ id: 1, userId: 42 }); + + await service.create(42, makeCreatePantryDto() as any); + + expect(prismaMock.pantryItem.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 42, productId: 1 }), + }), + ); + }); + + it('createAdmin kan skapa pantry-item för explicit userId', async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 99 }); + prismaMock.product.findUnique.mockResolvedValue({ id: 1 }); + prismaMock.pantryItem.create.mockResolvedValue({ id: 1, userId: 99 }); + + await service.createAdmin(1, makeCreatePantryDto({ userId: 99 }) as any, 99); + + expect(prismaMock.pantryItem.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 99, productId: 1 }), + }), + ); + }); + + it('createAdmin throws NotFoundException om target user saknas', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + + await expect(service.createAdmin(1, makeCreatePantryDto({ userId: 999 }) as any, 999)).rejects.toThrow( + NotFoundException, + ); + }); + + it('remove nekar icke-owner', async () => { + // Simulera att item inte hittas (userId matchar inte) + prismaMock.pantryItem.findFirst.mockResolvedValue(null); + + await expect(service.remove(42, 1)).rejects.toThrow(NotFoundException); + }); + + it('moveToInventory nekar icke-owner', async () => { + // Simulera att item inte hittas (userId matchar inte) + prismaMock.pantryItem.findFirst.mockResolvedValue(null); + + await expect(service.moveToInventory(42, 1, {} as any)).rejects.toThrow(NotFoundException); + }); +}); diff --git a/backend/src/pantry/pantry.security.spec.ts b/backend/src/pantry/pantry.security.spec.ts new file mode 100644 index 00000000..918fb229 --- /dev/null +++ b/backend/src/pantry/pantry.security.spec.ts @@ -0,0 +1,59 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { PantryController } from './pantry.controller'; +import { getRolesMetadata, mockHttpContext } from '../test-utils/security-test-helpers'; + +describe('Pantry admin security', () => { + const adminHandlers: Array<[string, Function]> = [ + ['findAllAdmin', PantryController.prototype.findAllAdmin], + ['createAdmin', PantryController.prototype.createAdmin], + ['updateAdmin', PantryController.prototype.updateAdmin], + ['removeAdmin', PantryController.prototype.removeAdmin], + ['moveToInventoryAdmin', PantryController.prototype.moveToInventoryAdmin], + ]; + + it('alla admin-endpoints har @Roles("admin") metadata', () => { + for (const [, handler] of adminHandlers) { + expect(getRolesMetadata(handler)).toEqual(['admin']); + } + }); + + it.each(adminHandlers)('RolesGuard nekar icke-admin (403) på %s', (_name, handler) => { + const reflector = new Reflector(); + const guard = new RolesGuard(reflector); + const context = mockHttpContext({ + handler, + clazz: PantryController, + user: { role: 'user' }, + }); + + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); + + it.each(adminHandlers)('RolesGuard tillåter admin (200/allow) på %s', (_name, handler) => { + const reflector = new Reflector(); + const guard = new RolesGuard(reflector); + const context = mockHttpContext({ + handler, + clazz: PantryController, + user: { role: 'admin' }, + }); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('JwtAuthGuard mappar saknad användare till 401', () => { + const guard = new JwtAuthGuard(new Reflector()); + + expect(() => guard.handleRequest(null, null, null)).toThrow(UnauthorizedException); + }); + + it('JwtAuthGuard släpper igenom autentiserad användare (200/allow)', () => { + const guard = new JwtAuthGuard(new Reflector()); + const user = { userId: 42, role: 'user' }; + + expect(guard.handleRequest(null, user, null)).toBe(user); + }); +}); diff --git a/backend/src/products/products.security.spec.ts b/backend/src/products/products.security.spec.ts new file mode 100644 index 00000000..4a361247 --- /dev/null +++ b/backend/src/products/products.security.spec.ts @@ -0,0 +1,119 @@ +import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator'; +import { ProductsController } from './products.controller'; +import { getRolesMetadata } from '../test-utils/security-test-helpers'; + +describe('Products controller security', () => { + const productsServiceMock = { + findByOwner: jest.fn(), + findOne: jest.fn(), + createPrivate: jest.fn(), + createPending: jest.fn(), + updateCanonicalNamePrivate: jest.fn(), + mergePrivate: jest.fn(), + }; + + const aiServiceMock = { + suggestCategory: jest.fn(), + }; + + const categoriesServiceMock = { + findFlattened: jest.fn(), + }; + + const controller = new ProductsController( + productsServiceMock as any, + aiServiceMock as any, + categoriesServiceMock as any, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ['findAll', ProductsController.prototype.findAll], + ['findAllTags', ProductsController.prototype.findAllTags], + ])('endpoint %s har @Public metadata', (_name, handler) => { + const isPublic = Reflect.getMetadata(IS_PUBLIC_KEY, handler) as boolean | undefined; + expect(isPublic).toBe(true); + }); + + it('alla admin-endpoints har @Roles("admin") metadata', () => { + for (const [, handler] of [ + ['findDuplicates', ProductsController.prototype.findDuplicates], + ['previewMerge', ProductsController.prototype.previewMerge], + ['backfillCanonical', ProductsController.prototype.backfillCanonical], + ['findPending', ProductsController.prototype.findPending], + ['findPrivate', ProductsController.prototype.findPrivate], + ['aiCategorizeBulk', ProductsController.prototype.aiCategorizeBulk], + ['findDeleted', ProductsController.prototype.findDeleted], + ['create', ProductsController.prototype.create], + ['merge', ProductsController.prototype.merge], + ['updateCanonicalName', ProductsController.prototype.updateCanonicalName], + ['setTags', ProductsController.prototype.setTags], + ['upsertNutrition', ProductsController.prototype.upsertNutrition], + ['update', ProductsController.prototype.update], + ['promotePrivateToGlobal', ProductsController.prototype.promotePrivateToGlobal], + ['permanentDelete', ProductsController.prototype.permanentDelete], + ['remove', ProductsController.prototype.remove], + ['setStatus', ProductsController.prototype.setStatus], + ['restore', ProductsController.prototype.restore], + ['resetAll', ProductsController.prototype.resetAll], + ['bulkUpdate', ProductsController.prototype.bulkUpdate], + ]) { + expect(getRolesMetadata(handler as Function)).toEqual(['admin']); + } + }); + + it('findMine vidarebefordrar req.user.id till owner-scope', () => { + productsServiceMock.findByOwner.mockResolvedValue([]); + + controller.findMine({ user: { id: 42 } } as any); + + expect(productsServiceMock.findByOwner).toHaveBeenCalledWith(42); + }); + + it('createPrivate vidarebefordrar req.user.id', () => { + const dto = { name: 'Private Product' }; + productsServiceMock.createPrivate.mockResolvedValue({ id: 1 }); + + controller.createPrivate(dto as any, { user: { id: 42 } } as any); + + expect(productsServiceMock.createPrivate).toHaveBeenCalledWith(dto, 42); + }); + + it('createPending vidarebefordrar req.user.id', () => { + const dto = { name: 'Pending Product' }; + productsServiceMock.createPending.mockResolvedValue({ id: 1 }); + + controller.createPending(dto as any, { user: { id: 42 } } as any); + + expect(productsServiceMock.createPending).toHaveBeenCalledWith(dto, 42); + }); + + it('updateCanonicalNamePrivate vidarebefordrar req.user.id', () => { + productsServiceMock.updateCanonicalNamePrivate.mockResolvedValue({ id: 1 }); + + controller.updateCanonicalNamePrivate(1, { canonicalName: 'milk' } as any, { user: { id: 42 } } as any); + + expect(productsServiceMock.updateCanonicalNamePrivate).toHaveBeenCalledWith(42, 1, 'milk'); + }); + + it('mergePrivate vidarebefordrar req.user.id', () => { + productsServiceMock.mergePrivate.mockResolvedValue({ merged: true }); + + controller.mergePrivate({ sourceProductId: 10, targetProductId: 20 } as any, { user: { id: 42 } } as any); + + expect(productsServiceMock.mergePrivate).toHaveBeenCalledWith(42, 10, 20); + }); + + it('suggestCategory använder canonicalName fallback name', async () => { + productsServiceMock.findOne.mockResolvedValue({ id: 1, name: 'Mjolk', canonicalName: 'Mjolk 1L' }); + categoriesServiceMock.findFlattened.mockResolvedValue([{ id: 1, name: 'Mejeri' }]); + aiServiceMock.suggestCategory.mockResolvedValue({ categoryId: 1 }); + + await controller.suggestCategory(1); + + expect(aiServiceMock.suggestCategory).toHaveBeenCalledWith('Mjolk 1L', [{ id: 1, name: 'Mejeri' }]); + }); +}); diff --git a/backend/src/receipt-alias/receipt-alias.security.spec.ts b/backend/src/receipt-alias/receipt-alias.security.spec.ts new file mode 100644 index 00000000..d6ebd281 --- /dev/null +++ b/backend/src/receipt-alias/receipt-alias.security.spec.ts @@ -0,0 +1,40 @@ +import { ReceiptAliasController } from './receipt-alias.controller'; + +describe('ReceiptAlias controller security', () => { + const receiptAliasServiceMock = { + findAllForUser: jest.fn(), + upsert: jest.fn(), + remove: jest.fn(), + }; + + const controller = new ReceiptAliasController(receiptAliasServiceMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('findAll scopear till @CurrentUser userId + role', () => { + receiptAliasServiceMock.findAllForUser.mockResolvedValue([]); + + controller.findAll({ userId: 42, role: 'user' }); + + expect(receiptAliasServiceMock.findAllForUser).toHaveBeenCalledWith(42, 'user'); + }); + + it('upsert scopear till @CurrentUser userId + role', () => { + const dto = { receiptName: 'Mjolk', productName: 'Mjolk' }; + receiptAliasServiceMock.upsert.mockResolvedValue({ id: 1 }); + + controller.upsert(dto as any, { userId: 42, role: 'admin' }); + + expect(receiptAliasServiceMock.upsert).toHaveBeenCalledWith(dto, 42, 'admin'); + }); + + it('remove scopear till @CurrentUser userId + role', () => { + receiptAliasServiceMock.remove.mockResolvedValue({ removed: true }); + + controller.remove(10, { userId: 42, role: 'user' }); + + expect(receiptAliasServiceMock.remove).toHaveBeenCalledWith(10, 42, 'user'); + }); +}); diff --git a/backend/src/receipt-import/receipt-import.security.spec.ts b/backend/src/receipt-import/receipt-import.security.spec.ts new file mode 100644 index 00000000..77f1cc05 --- /dev/null +++ b/backend/src/receipt-import/receipt-import.security.spec.ts @@ -0,0 +1,86 @@ +import { BadRequestException } from '@nestjs/common'; +import { ReceiptImportController } from './receipt-import.controller'; + +describe('ReceiptImport controller security', () => { + const receiptImportServiceMock = { + parseReceipt: jest.fn(), + upsertUnitMapping: jest.fn(), + saveReceipt: jest.fn(), + }; + + const controller = new ReceiptImportController(receiptImportServiceMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('parseReceipt nekar när fil saknas', async () => { + await expect(controller.parseReceipt(undefined, { user: { id: 1 } })).rejects.toThrow(BadRequestException); + }); + + it('parseReceipt nekar otillåten mime-type', async () => { + const file = { buffer: Buffer.from('x'), mimetype: 'text/plain' } as any; + + await expect(controller.parseReceipt(file, { user: { id: 1 } })).rejects.toThrow(BadRequestException); + }); + + it('parseReceipt vidarebefordrar premium/admin och userId', async () => { + const file = { buffer: Buffer.from('x'), mimetype: 'image/jpeg' } as any; + receiptImportServiceMock.parseReceipt.mockResolvedValue([]); + + await controller.parseReceipt(file, { user: { id: 42, role: 'admin', isPremium: false } }); + + expect(receiptImportServiceMock.parseReceipt).toHaveBeenCalledWith(file, true, 42); + }); + + it('upsertUnitMapping använder req.user.id när tillgänglig', async () => { + receiptImportServiceMock.upsertUnitMapping.mockResolvedValue({ ok: true }); + + await controller.upsertUnitMapping( + { productId: 1, originalUnit: 'g', preferredUnit: 'kg' } as any, + { user: { id: 42 } }, + ); + + expect(receiptImportServiceMock.upsertUnitMapping).toHaveBeenCalledWith(42, 1, 'g', 'kg'); + }); + + it('upsertUnitMapping fallbackar till req.user.userId', async () => { + receiptImportServiceMock.upsertUnitMapping.mockResolvedValue({ ok: true }); + + await controller.upsertUnitMapping( + { productId: 1, originalUnit: 'g', preferredUnit: 'kg' } as any, + { user: { userId: 99 } }, + ); + + expect(receiptImportServiceMock.upsertUnitMapping).toHaveBeenCalledWith(99, 1, 'g', 'kg'); + }); + + it('upsertUnitMapping nekar när användar-id saknas', async () => { + await expect( + controller.upsertUnitMapping({ productId: 1, originalUnit: 'g', preferredUnit: 'kg' } as any, { user: {} }), + ).rejects.toThrow(BadRequestException); + }); + + it('saveReceipt nekar global alias för icke-admin', async () => { + const dto = { + items: [{ learnAliasGlobally: true }], + }; + + await expect(controller.saveReceipt(dto as any, { user: { id: 42, role: 'user' } })).rejects.toThrow( + BadRequestException, + ); + + expect(receiptImportServiceMock.saveReceipt).not.toHaveBeenCalled(); + }); + + it('saveReceipt tillåter global alias för admin', async () => { + const dto = { + items: [{ learnAliasGlobally: true }], + }; + receiptImportServiceMock.saveReceipt.mockResolvedValue({ success: true }); + + await controller.saveReceipt(dto as any, { user: { userId: 42, role: 'admin' } }); + + expect(receiptImportServiceMock.saveReceipt).toHaveBeenCalledWith(42, dto); + }); +}); diff --git a/backend/src/recipes/recipes.idor.spec.ts b/backend/src/recipes/recipes.idor.spec.ts new file mode 100644 index 00000000..e774dfe6 --- /dev/null +++ b/backend/src/recipes/recipes.idor.spec.ts @@ -0,0 +1,149 @@ +import { NotFoundException } from '@nestjs/common'; +import { RecipesService } from './recipes.service'; + +describe('RecipesService IDOR security', () => { + const prismaMock = { + recipe: { + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + recipeIngredient: { + findMany: jest.fn(), + create: jest.fn(), + deleteMany: jest.fn(), + }, + product: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + recipeShare: { + findUnique: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + $transaction: jest.fn(), + }; + + const aiServiceMock = {}; + const recipeMatchingServiceMock = {}; + + const service = new RecipesService(prismaMock as any, aiServiceMock as any, recipeMatchingServiceMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setRecipeAsOwnedByAnotherUser = () => { + prismaMock.recipe.findUnique.mockResolvedValue({ + id: 1, + ownerId: 100, + }); + }; + + it('findAll scopar resultaten till userId (owner eller shared)', async () => { + prismaMock.recipe.findMany.mockResolvedValue([]); + + await service.findAll(42); + + expect(prismaMock.recipe.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + { ownerId: 42 }, + { isPublic: true }, + { shares: { some: { userId: 42 } } }, + ]), + }), + }), + ); + }); + + it('findOne nekar access om recipe inte är owner/shared/public', async () => { + prismaMock.recipe.findFirst.mockResolvedValue(null); + + await expect(service.findOne(1, 42)).rejects.toThrow(NotFoundException); + }); + + it('findOne tillåter owner att läsa eget recipe', async () => { + prismaMock.recipe.findFirst.mockResolvedValue({ + id: 1, + ownerId: 42, + isPublic: false, + name: 'Test Recipe', + description: '', + instructions: '', + mealPlanDayOfWeek: null, + imageUrl: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.findOne(1, 42); + expect(result).toBeDefined(); + }); + + it('findOne tillåter shared user att läsa shared recipe', async () => { + prismaMock.recipe.findFirst.mockResolvedValue({ + id: 1, + ownerId: 100, + isPublic: false, + shares: [{ userId: 42 }], + }); + + const result = await service.findOne(1, 42); + expect(result).toBeDefined(); + }); + + it('findOne tillåter alla att läsa public recipe', async () => { + prismaMock.recipe.findFirst.mockResolvedValue({ + id: 1, + ownerId: 100, + isPublic: true, + }); + + const result = await service.findOne(1, 42); + expect(result).toBeDefined(); + }); + + it.each([ + { + name: 'update', + action: () => service.update(1, {} as any, 42), + }, + { + name: 'remove', + action: () => service.remove(1, 42), + }, + { + name: 'shareWithUser', + action: () => service.shareWithUser(1, 42, 'someuser'), + }, + { + name: 'unshareWithUser', + action: () => service.unshareWithUser(1, 42, 'someuser'), + }, + { + name: 'setVisibility', + action: () => service.setVisibility(1, 42, true), + }, + { + name: 'addIngredient', + action: () => service.addIngredient(1, { productId: 1, quantity: 100 } as any, 42), + }, + { + name: 'updateImage', + action: () => service.updateImage(1, 'http://example.com/image.jpg', 42), + }, + ])('%s nekar icke-owner', async ({ action }) => { + setRecipeAsOwnedByAnotherUser(); + + await expect(action()).rejects.toThrow(NotFoundException); + }); +}); diff --git a/backend/src/recipes/recipes.security.spec.ts b/backend/src/recipes/recipes.security.spec.ts new file mode 100644 index 00000000..96cd0d5d --- /dev/null +++ b/backend/src/recipes/recipes.security.spec.ts @@ -0,0 +1,100 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RecipesController } from './recipes.controller'; + +describe('Recipes controller security', () => { + const makeUser = (overrides: Partial<{ userId: number; role: string }> = {}) => ({ + userId: 42, + role: 'user', + ...overrides, + }); + + const recipesServiceMock = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + shareWithUser: jest.fn(), + unshareWithUser: jest.fn(), + setVisibility: jest.fn(), + }; + + const controller = new RecipesController(recipesServiceMock as any, {} as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('JwtAuthGuard kräver autentisering på findAll', () => { + const guard = new JwtAuthGuard(new Reflector()); + + expect(() => guard.handleRequest(null, null, null)).toThrow(UnauthorizedException); + }); + + it('JwtAuthGuard tillåter autentiserad användare på findAll', () => { + const guard = new JwtAuthGuard(new Reflector()); + const user = makeUser({ userId: 1 }); + + expect(guard.handleRequest(null, user, null)).toBe(user); + }); + + it('findOne vidarebefordrar @CurrentUser.userId till service', () => { + recipesServiceMock.findOne.mockResolvedValue({ id: 1 }); + + controller.findOne(1, makeUser()); + + expect(recipesServiceMock.findOne).toHaveBeenCalledWith(1, 42); + }); + + it('create vidarebefordrar @CurrentUser.userId till service', async () => { + const dto = { name: 'test' }; + recipesServiceMock.create.mockResolvedValue({ id: 1 }); + + await controller.create(dto as any, makeUser()); + + expect(recipesServiceMock.create).toHaveBeenCalledWith(dto, 42); + }); + + it('update vidarebefordrar @CurrentUser.userId till service', async () => { + const dto = { name: 'updated' }; + recipesServiceMock.update.mockResolvedValue({ id: 1 }); + + await controller.update(1, dto as any, makeUser()); + + expect(recipesServiceMock.update).toHaveBeenCalledWith(1, dto, 42); + }); + + it('remove vidarebefordrar @CurrentUser.userId till service', async () => { + recipesServiceMock.remove.mockResolvedValue(undefined); + + await controller.remove(1, makeUser()); + + expect(recipesServiceMock.remove).toHaveBeenCalledWith(1, 42); + }); + + it('shareRecipe vidarebefordrar userId och trimmar username', async () => { + recipesServiceMock.shareWithUser.mockResolvedValue(undefined); + + await controller.shareRecipe(1, { username: ' alice ' } as any, makeUser()); + + expect(recipesServiceMock.shareWithUser).toHaveBeenCalledWith(1, 42, 'alice'); + }); + + it('unshareRecipe vidarebefordrar userId och trimmar username', async () => { + recipesServiceMock.unshareWithUser.mockResolvedValue(undefined); + + await controller.unshareRecipe(1, ' alice ', makeUser()); + + expect(recipesServiceMock.unshareWithUser).toHaveBeenCalledWith(1, 42, 'alice'); + }); + + it('setVisibility vidarebefordrar userId till service', async () => { + recipesServiceMock.setVisibility.mockResolvedValue(undefined); + + await controller.setVisibility(1, { isPublic: true } as any, makeUser()); + + expect(recipesServiceMock.setVisibility).toHaveBeenCalledWith(1, 42, true); + }); +}); diff --git a/backend/src/test-utils/security-test-helpers.ts b/backend/src/test-utils/security-test-helpers.ts new file mode 100644 index 00000000..44a8d706 --- /dev/null +++ b/backend/src/test-utils/security-test-helpers.ts @@ -0,0 +1,26 @@ +import { ExecutionContext } from '@nestjs/common'; +import { ROLES_KEY } from '../auth/decorators/roles.decorator'; + +export type MockHttpContextOptions = { + handler: Function; + clazz: Function; + user?: unknown; +}; + +export type AdminHandler = [string, Function]; + +export function mockHttpContext(options: MockHttpContextOptions): ExecutionContext { + return { + getClass: () => options.clazz, + getHandler: () => options.handler, + switchToHttp: () => ({ + getRequest: () => ({ user: options.user }), + getResponse: () => ({}), + getNext: () => undefined, + }), + } as unknown as ExecutionContext; +} + +export function getRolesMetadata(handler: Function): string[] | undefined { + return Reflect.getMetadata(ROLES_KEY, handler) as string[] | undefined; +} diff --git a/backend/src/users/users.security.spec.ts b/backend/src/users/users.security.spec.ts new file mode 100644 index 00000000..0e43a4a1 --- /dev/null +++ b/backend/src/users/users.security.spec.ts @@ -0,0 +1,101 @@ +import { BadRequestException } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { getRolesMetadata } from '../test-utils/security-test-helpers'; + +describe('Users controller security', () => { + const usersServiceMock = { + findById: jest.fn(), + updateProfile: jest.fn(), + setRole: jest.fn(), + deleteUser: jest.fn(), + resetPassword: jest.fn(), + updateEmail: jest.fn(), + }; + + const controller = new UsersController(usersServiceMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('alla admin-endpoints har @Roles("admin") metadata', () => { + for (const [, handler] of [ + ['listUsers', UsersController.prototype.listUsers], + ['setRole', UsersController.prototype.setRole], + ['setPremium', UsersController.prototype.setPremium], + ['setRecipeSharing', UsersController.prototype.setRecipeSharing], + ['setAiEngineEnabled', UsersController.prototype.setAiEngineEnabled], + ['adminCreateUser', UsersController.prototype.adminCreateUser], + ['deleteUser', UsersController.prototype.deleteUser], + ['resetPassword', UsersController.prototype.resetPassword], + ['updateEmail', UsersController.prototype.updateEmail], + ]) { + expect(getRolesMetadata(handler as Function)).toEqual(['admin']); + } + }); + + it('getMe scopear till @CurrentUser.userId', async () => { + usersServiceMock.findById.mockResolvedValue({ + id: 42, + username: 'alice', + email: 'a@example.com', + firstName: 'Alice', + lastName: 'Doe', + role: 'user', + }); + + const result = await controller.getMe({ userId: 42, username: 'alice' }); + + expect(usersServiceMock.findById).toHaveBeenCalledWith(42); + expect(result).toEqual( + expect.objectContaining({ + id: 42, + username: 'alice', + role: 'user', + }), + ); + }); + + it('updateMe scopear till @CurrentUser.userId', async () => { + const dto = { firstName: 'New' }; + usersServiceMock.updateProfile.mockResolvedValue({ + id: 42, + username: 'alice', + email: 'a@example.com', + firstName: 'New', + lastName: 'Doe', + }); + + await controller.updateMe({ userId: 42, username: 'alice' }, dto); + + expect(usersServiceMock.updateProfile).toHaveBeenCalledWith(42, dto); + }); + + it('setRole nekar att ändra sin egen roll', async () => { + await expect( + controller.setRole(42, { userId: 42, username: 'alice', role: 'admin' }, { role: 'user' } as any), + ).rejects.toThrow(BadRequestException); + + expect(usersServiceMock.setRole).not.toHaveBeenCalled(); + }); + + it('deleteUser nekar att ta bort eget konto', async () => { + await expect(controller.deleteUser(42, { userId: 42 })).rejects.toThrow(BadRequestException); + + expect(usersServiceMock.deleteUser).not.toHaveBeenCalled(); + }); + + it('resetPassword nekar self-reset via adminendpoint', async () => { + await expect(controller.resetPassword(42, { userId: 42 })).rejects.toThrow(BadRequestException); + + expect(usersServiceMock.resetPassword).not.toHaveBeenCalled(); + }); + + it('updateEmail nekar egen e-poständring via adminendpoint', async () => { + await expect(controller.updateEmail(42, { userId: 42 }, { email: 'new@example.com' } as any)).rejects.toThrow( + BadRequestException, + ); + + expect(usersServiceMock.updateEmail).not.toHaveBeenCalled(); + }); +});