From 17893824d581a924549e2deb6c866b4cd9eae527 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Thu, 7 May 2026 11:58:00 +0200 Subject: [PATCH] feat: implement user-specific inventory management with security checks --- .env.example | 7 ++ .github/workflows/test.yml | 10 ++- .gitignore | 7 ++ .../migration.sql | 18 ++++ backend/prisma/schema.prisma | 4 + backend/src/inventory/inventory.controller.ts | 35 +++++--- .../src/inventory/inventory.service.spec.ts | 88 +++++++++++++++++++ backend/src/inventory/inventory.service.ts | 43 +++++---- 8 files changed, 181 insertions(+), 31 deletions(-) create mode 100644 backend/prisma/migrations/20260507203000_inventory_item_user_scope/migration.sql create mode 100644 backend/src/inventory/inventory.service.spec.ts diff --git a/.env.example b/.env.example index 981f663a..5a845101 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,13 @@ NEXT_PUBLIC_API_URL=https://recept.gynther.se # CORS — tillåtna origins för backend-API (normalt samma som APP_URL) ALLOWED_ORIGIN=https://recept.gynther.se +# Importer integration +IMPORTER_SERVICE_URL=http://importer-api:3001 +RECEIPT_TRACE_DECISIONS=0 + +# Optional webhook hardening +GITEA_WEBHOOK_SECRET= + # Bootstrap-användare (skapas/uppdateras vid appstart) ADMIN_NADMIN_PASSWORD=byt-ut-mig ADMIN_PADMIN_PASSWORD=byt-ut-mig diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1d225d11..7fe7d6b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,12 +26,20 @@ jobs: - name: Install dependencies (backend) working-directory: ./backend - run: npm install + run: npm ci - name: Generate Prisma Client working-directory: ./backend run: npm run prisma:generate + - name: Prisma schema validate + working-directory: ./backend + run: npx prisma validate --schema prisma/schema.prisma + + - name: Dependency audit (high+critical) + working-directory: ./backend + run: npm audit --audit-level=high + - name: Run tests (backend) working-directory: ./backend run: npm test diff --git a/.gitignore b/.gitignore index c552ab8f..829ff0ea 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,14 @@ node_modules/ !package-lock.json !**/package-lock.json +# Environment files +.env .env.* +!.env.example + +# Compiled backend artifacts (built in Docker) +backend/dist/ +backend/tsconfig.tsbuildinfo # Dart/Flutter generated files with absolute host paths — must not be committed .dart_tool/ diff --git a/backend/prisma/migrations/20260507203000_inventory_item_user_scope/migration.sql b/backend/prisma/migrations/20260507203000_inventory_item_user_scope/migration.sql new file mode 100644 index 00000000..491e4791 --- /dev/null +++ b/backend/prisma/migrations/20260507203000_inventory_item_user_scope/migration.sql @@ -0,0 +1,18 @@ +ALTER TABLE `InventoryItem` + ADD COLUMN `userId` INT NULL; + +UPDATE `InventoryItem` AS ii +JOIN `Product` AS p ON p.`id` = ii.`productId` +SET ii.`userId` = p.`ownerId` +WHERE ii.`userId` IS NULL; + +SET @fallback_user_id := (SELECT `id` FROM `User` ORDER BY `id` ASC LIMIT 1); +UPDATE `InventoryItem` +SET `userId` = @fallback_user_id +WHERE `userId` IS NULL; + +ALTER TABLE `InventoryItem` + MODIFY COLUMN `userId` INT NOT NULL, + ADD INDEX `InventoryItem_userId_idx` (`userId`), + ADD CONSTRAINT `InventoryItem_userId_fkey` + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index abbe0f53..8ac595bb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { ownedRecipes Recipe[] @relation("RecipeOwner") sharedRecipes RecipeShare[] ownedProducts Product[] + inventoryItems InventoryItem[] pantryItems PantryItem[] mealPlanEntries MealPlanEntry[] receiptAliases ReceiptAlias[] @@ -91,6 +92,7 @@ model UserProduct { model InventoryItem { id Int @id @default(autoincrement()) + userId Int productId Int quantity Decimal @db.Decimal(10, 2) unit String @@ -107,9 +109,11 @@ model InventoryItem { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade) consumptions InventoryConsumption[] + @@index([userId]) @@index([productId]) } diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts index 7ed35d36..8eda2a2e 100644 --- a/backend/src/inventory/inventory.controller.ts +++ b/backend/src/inventory/inventory.controller.ts @@ -13,6 +13,7 @@ import { CreateInventoryDto } from './dto/create-inventory.dto'; import { UpdateInventoryDto } from './dto/update-inventory.dto'; import { InventoryService } from './inventory.service'; import { ConsumeInventoryDto } from './dto/consume-inventory.dto'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; @Controller('inventory') export class InventoryController { @@ -20,45 +21,57 @@ export class InventoryController { @Post(':id/consume') consume( + @CurrentUser() user: { userId: number }, @Param('id', ParseIntPipe) id: number, @Body() body: ConsumeInventoryDto, ) { - return this.inventoryService.consume(id, body); + return this.inventoryService.consume(id, user.userId, body); } @Get(':id/consumption-history') -findConsumptionHistory(@Param('id', ParseIntPipe) id: number) { - return this.inventoryService.findConsumptionHistory(id); +findConsumptionHistory( + @CurrentUser() user: { userId: number }, + @Param('id', ParseIntPipe) id: number, +) { + return this.inventoryService.findConsumptionHistory(id, user.userId); } @Get() findAll( + @CurrentUser() user: { userId: number }, @Query('location') location?: string, @Query('sort') sort?: string, ) { - return this.inventoryService.findAll({ location, sort }); + return this.inventoryService.findAll(user.userId, { location, sort }); } @Get('expiring') - findExpiring() { - return this.inventoryService.findExpiring(); + findExpiring(@CurrentUser() user: { userId: number }) { + return this.inventoryService.findExpiring(user.userId); } @Post() - create(@Body() body: CreateInventoryDto) { - return this.inventoryService.create(body); + create( + @CurrentUser() user: { userId: number }, + @Body() body: CreateInventoryDto, + ) { + return this.inventoryService.create(user.userId, body); } @Patch(':id') update( + @CurrentUser() user: { userId: number }, @Param('id', ParseIntPipe) id: number, @Body() body: UpdateInventoryDto, ) { - return this.inventoryService.update(id, body); + return this.inventoryService.update(id, user.userId, body); } @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number) { - return this.inventoryService.remove(id); + remove( + @CurrentUser() user: { userId: number }, + @Param('id', ParseIntPipe) id: number, + ) { + return this.inventoryService.remove(id, user.userId); } } \ No newline at end of file diff --git a/backend/src/inventory/inventory.service.spec.ts b/backend/src/inventory/inventory.service.spec.ts new file mode 100644 index 00000000..946b3fb0 --- /dev/null +++ b/backend/src/inventory/inventory.service.spec.ts @@ -0,0 +1,88 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { InventoryService } from './inventory.service'; + +describe('InventoryService security', () => { + const prismaMock = { + inventoryItem: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + inventoryConsumption: { + findMany: jest.fn(), + create: jest.fn(), + }, + product: { + findFirst: jest.fn(), + }, + $transaction: jest.fn(), + }; + + const service = new InventoryService(prismaMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('scopar findAll till userId', async () => { + prismaMock.inventoryItem.findMany.mockResolvedValue([]); + + await service.findAll(42, { location: 'fridge', sort: 'bestBeforeAsc' }); + + expect(prismaMock.inventoryItem.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: 42, location: 'fridge' }), + }), + ); + }); + + it('sätter userId vid create', async () => { + prismaMock.product.findFirst.mockResolvedValue({ id: 99, ownerId: 42 }); + prismaMock.inventoryItem.create.mockResolvedValue({ id: 1 }); + + await service.create(42, { + productId: 99, + quantity: 2, + unit: 'st', + } as any); + + expect(prismaMock.inventoryItem.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 42, productId: 99 }), + }), + ); + }); + + it('nekar update om inventoryItem tillhör annan användare', async () => { + prismaMock.inventoryItem.findUnique.mockResolvedValue({ + id: 10, + userId: 7, + quantity: new Prisma.Decimal(1), + }); + + await expect(service.update(10, 42, {} as any)).rejects.toThrow(ForbiddenException); + }); + + it('nekar remove om inventoryItem tillhör annan användare', async () => { + prismaMock.inventoryItem.findUnique.mockResolvedValue({ + id: 11, + userId: 7, + quantity: new Prisma.Decimal(1), + }); + + await expect(service.remove(11, 42)).rejects.toThrow(ForbiddenException); + }); + + it('nekar consumption-history om inventoryItem tillhör annan användare', async () => { + prismaMock.inventoryItem.findUnique.mockResolvedValue({ + id: 12, + userId: 7, + quantity: new Prisma.Decimal(1), + }); + + await expect(service.findConsumptionHistory(12, 42)).rejects.toThrow(ForbiddenException); + }); +}); diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index 6982490e..00139545 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -1,5 +1,5 @@ import { ConsumeInventoryDto } from './dto/consume-inventory.dto'; -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateInventoryDto } from './dto/create-inventory.dto'; @@ -32,24 +32,27 @@ export class InventoryService { throw new NotFoundException(`Inventory item with id ${id} not found`); } - private async findInventoryItemByIdOrThrow(id: number) { + private async findInventoryItemByIdOrThrow(id: number, userId: number) { const existing = await this.prisma.inventoryItem.findUnique({ where: { id } }); if (!existing) { this.throwInventoryItemNotFound(id); } + if (existing.userId !== userId) { + throw new ForbiddenException(`Inventory item with id ${id} does not belong to current user`); + } return existing; } - private async ensureProductExists(productId: number) { - const product = await this.prisma.product.findUnique({ where: { id: productId } }); + private async ensureProductExists(productId: number, userId: number) { + const product = await this.prisma.product.findFirst({ where: { id: productId, ownerId: userId } }); if (!product) { - throw new NotFoundException('Product not found'); + throw new NotFoundException('Product not found for current user'); } return product; } - async findAll(query?: InventoryQuery) { - const where: Prisma.InventoryItemWhereInput = {}; + async findAll(userId: number, query?: InventoryQuery) { + const where: Prisma.InventoryItemWhereInput = { userId }; const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = []; if (query?.location) { @@ -79,8 +82,8 @@ export class InventoryService { }); } - async consume(id: number, data: ConsumeInventoryDto) { - const existing = await this.findInventoryItemByIdOrThrow(id); + async consume(id: number, userId: number, data: ConsumeInventoryDto) { + const existing = await this.findInventoryItemByIdOrThrow(id, userId); const currentQuantity = Number(existing.quantity); const newQuantity = Math.max(0, currentQuantity - data.amountUsed); @@ -108,8 +111,8 @@ export class InventoryService { }); } - async findConsumptionHistory(id: number) { - await this.findInventoryItemByIdOrThrow(id); + async findConsumptionHistory(id: number, userId: number) { + await this.findInventoryItemByIdOrThrow(id, userId); return this.prisma.inventoryConsumption.findMany({ where: { @@ -130,11 +133,12 @@ export class InventoryService { }, }); } - async findExpiring() { + async findExpiring(userId: number) { const now = new Date(); return this.prisma.inventoryItem.findMany({ where: { + userId, bestBeforeDate: { not: null, gte: now, @@ -147,12 +151,13 @@ export class InventoryService { }); } - async create(data: CreateInventoryDto) { - await this.ensureProductExists(data.productId); + async create(userId: number, data: CreateInventoryDto) { + await this.ensureProductExists(data.productId, userId); return this.prisma.inventoryItem.create({ data: { ...data, + userId, quantity: new Prisma.Decimal(data.quantity), location: data.location?.trim() || undefined, brand: data.brand?.trim() || undefined, @@ -173,11 +178,11 @@ export class InventoryService { }); } - async update(id: number, data: UpdateInventoryDto) { - await this.findInventoryItemByIdOrThrow(id); + async update(id: number, userId: number, data: UpdateInventoryDto) { + await this.findInventoryItemByIdOrThrow(id, userId); if (typeof data.productId === 'number') { - await this.ensureProductExists(data.productId); + await this.ensureProductExists(data.productId, userId); } const updateData: Prisma.InventoryItemUpdateInput = {}; @@ -241,8 +246,8 @@ export class InventoryService { }); } - async remove(id: number) { - await this.findInventoryItemByIdOrThrow(id); + async remove(id: number, userId: number) { + await this.findInventoryItemByIdOrThrow(id, userId); return this.prisma.inventoryItem.delete({ where: { id } }); } } \ No newline at end of file