feat: implement user-specific inventory management with security checks
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-07 11:58:00 +02:00
parent 4affb03504
commit 17893824d5
8 changed files with 181 additions and 31 deletions
+7
View File
@@ -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) # CORS — tillåtna origins för backend-API (normalt samma som APP_URL)
ALLOWED_ORIGIN=https://recept.gynther.se 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) # Bootstrap-användare (skapas/uppdateras vid appstart)
ADMIN_NADMIN_PASSWORD=byt-ut-mig ADMIN_NADMIN_PASSWORD=byt-ut-mig
ADMIN_PADMIN_PASSWORD=byt-ut-mig ADMIN_PADMIN_PASSWORD=byt-ut-mig
+9 -1
View File
@@ -26,12 +26,20 @@ jobs:
- name: Install dependencies (backend) - name: Install dependencies (backend)
working-directory: ./backend working-directory: ./backend
run: npm install run: npm ci
- name: Generate Prisma Client - name: Generate Prisma Client
working-directory: ./backend working-directory: ./backend
run: npm run prisma:generate 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) - name: Run tests (backend)
working-directory: ./backend working-directory: ./backend
run: npm test run: npm test
+7
View File
@@ -6,7 +6,14 @@ node_modules/
!package-lock.json !package-lock.json
!**/package-lock.json !**/package-lock.json
# Environment files
.env
.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/Flutter generated files with absolute host paths — must not be committed
.dart_tool/ .dart_tool/
@@ -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;
+4
View File
@@ -25,6 +25,7 @@ model User {
ownedRecipes Recipe[] @relation("RecipeOwner") ownedRecipes Recipe[] @relation("RecipeOwner")
sharedRecipes RecipeShare[] sharedRecipes RecipeShare[]
ownedProducts Product[] ownedProducts Product[]
inventoryItems InventoryItem[]
pantryItems PantryItem[] pantryItems PantryItem[]
mealPlanEntries MealPlanEntry[] mealPlanEntries MealPlanEntry[]
receiptAliases ReceiptAlias[] receiptAliases ReceiptAlias[]
@@ -91,6 +92,7 @@ model UserProduct {
model InventoryItem { model InventoryItem {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int
productId Int productId Int
quantity Decimal @db.Decimal(10, 2) quantity Decimal @db.Decimal(10, 2)
unit String unit String
@@ -107,9 +109,11 @@ model InventoryItem {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
consumptions InventoryConsumption[] consumptions InventoryConsumption[]
@@index([userId])
@@index([productId]) @@index([productId])
} }
+24 -11
View File
@@ -13,6 +13,7 @@ import { CreateInventoryDto } from './dto/create-inventory.dto';
import { UpdateInventoryDto } from './dto/update-inventory.dto'; import { UpdateInventoryDto } from './dto/update-inventory.dto';
import { InventoryService } from './inventory.service'; import { InventoryService } from './inventory.service';
import { ConsumeInventoryDto } from './dto/consume-inventory.dto'; import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@Controller('inventory') @Controller('inventory')
export class InventoryController { export class InventoryController {
@@ -20,45 +21,57 @@ export class InventoryController {
@Post(':id/consume') @Post(':id/consume')
consume( consume(
@CurrentUser() user: { userId: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() body: ConsumeInventoryDto, @Body() body: ConsumeInventoryDto,
) { ) {
return this.inventoryService.consume(id, body); return this.inventoryService.consume(id, user.userId, body);
} }
@Get(':id/consumption-history') @Get(':id/consumption-history')
findConsumptionHistory(@Param('id', ParseIntPipe) id: number) { findConsumptionHistory(
return this.inventoryService.findConsumptionHistory(id); @CurrentUser() user: { userId: number },
@Param('id', ParseIntPipe) id: number,
) {
return this.inventoryService.findConsumptionHistory(id, user.userId);
} }
@Get() @Get()
findAll( findAll(
@CurrentUser() user: { userId: number },
@Query('location') location?: string, @Query('location') location?: string,
@Query('sort') sort?: string, @Query('sort') sort?: string,
) { ) {
return this.inventoryService.findAll({ location, sort }); return this.inventoryService.findAll(user.userId, { location, sort });
} }
@Get('expiring') @Get('expiring')
findExpiring() { findExpiring(@CurrentUser() user: { userId: number }) {
return this.inventoryService.findExpiring(); return this.inventoryService.findExpiring(user.userId);
} }
@Post() @Post()
create(@Body() body: CreateInventoryDto) { create(
return this.inventoryService.create(body); @CurrentUser() user: { userId: number },
@Body() body: CreateInventoryDto,
) {
return this.inventoryService.create(user.userId, body);
} }
@Patch(':id') @Patch(':id')
update( update(
@CurrentUser() user: { userId: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() body: UpdateInventoryDto, @Body() body: UpdateInventoryDto,
) { ) {
return this.inventoryService.update(id, body); return this.inventoryService.update(id, user.userId, body);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) { remove(
return this.inventoryService.remove(id); @CurrentUser() user: { userId: number },
@Param('id', ParseIntPipe) id: number,
) {
return this.inventoryService.remove(id, user.userId);
} }
} }
@@ -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);
});
});
+24 -19
View File
@@ -1,5 +1,5 @@
import { ConsumeInventoryDto } from './dto/consume-inventory.dto'; 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 { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateInventoryDto } from './dto/create-inventory.dto'; import { CreateInventoryDto } from './dto/create-inventory.dto';
@@ -32,24 +32,27 @@ export class InventoryService {
throw new NotFoundException(`Inventory item with id ${id} not found`); 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 } }); const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
if (!existing) { if (!existing) {
this.throwInventoryItemNotFound(id); this.throwInventoryItemNotFound(id);
} }
if (existing.userId !== userId) {
throw new ForbiddenException(`Inventory item with id ${id} does not belong to current user`);
}
return existing; return existing;
} }
private async ensureProductExists(productId: number) { private async ensureProductExists(productId: number, userId: number) {
const product = await this.prisma.product.findUnique({ where: { id: productId } }); const product = await this.prisma.product.findFirst({ where: { id: productId, ownerId: userId } });
if (!product) { if (!product) {
throw new NotFoundException('Product not found'); throw new NotFoundException('Product not found for current user');
} }
return product; return product;
} }
async findAll(query?: InventoryQuery) { async findAll(userId: number, query?: InventoryQuery) {
const where: Prisma.InventoryItemWhereInput = {}; const where: Prisma.InventoryItemWhereInput = { userId };
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = []; const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
if (query?.location) { if (query?.location) {
@@ -79,8 +82,8 @@ export class InventoryService {
}); });
} }
async consume(id: number, data: ConsumeInventoryDto) { async consume(id: number, userId: number, data: ConsumeInventoryDto) {
const existing = await this.findInventoryItemByIdOrThrow(id); const existing = await this.findInventoryItemByIdOrThrow(id, userId);
const currentQuantity = Number(existing.quantity); const currentQuantity = Number(existing.quantity);
const newQuantity = Math.max(0, currentQuantity - data.amountUsed); const newQuantity = Math.max(0, currentQuantity - data.amountUsed);
@@ -108,8 +111,8 @@ export class InventoryService {
}); });
} }
async findConsumptionHistory(id: number) { async findConsumptionHistory(id: number, userId: number) {
await this.findInventoryItemByIdOrThrow(id); await this.findInventoryItemByIdOrThrow(id, userId);
return this.prisma.inventoryConsumption.findMany({ return this.prisma.inventoryConsumption.findMany({
where: { where: {
@@ -130,11 +133,12 @@ export class InventoryService {
}, },
}); });
} }
async findExpiring() { async findExpiring(userId: number) {
const now = new Date(); const now = new Date();
return this.prisma.inventoryItem.findMany({ return this.prisma.inventoryItem.findMany({
where: { where: {
userId,
bestBeforeDate: { bestBeforeDate: {
not: null, not: null,
gte: now, gte: now,
@@ -147,12 +151,13 @@ export class InventoryService {
}); });
} }
async create(data: CreateInventoryDto) { async create(userId: number, data: CreateInventoryDto) {
await this.ensureProductExists(data.productId); await this.ensureProductExists(data.productId, userId);
return this.prisma.inventoryItem.create({ return this.prisma.inventoryItem.create({
data: { data: {
...data, ...data,
userId,
quantity: new Prisma.Decimal(data.quantity), quantity: new Prisma.Decimal(data.quantity),
location: data.location?.trim() || undefined, location: data.location?.trim() || undefined,
brand: data.brand?.trim() || undefined, brand: data.brand?.trim() || undefined,
@@ -173,11 +178,11 @@ export class InventoryService {
}); });
} }
async update(id: number, data: UpdateInventoryDto) { async update(id: number, userId: number, data: UpdateInventoryDto) {
await this.findInventoryItemByIdOrThrow(id); await this.findInventoryItemByIdOrThrow(id, userId);
if (typeof data.productId === 'number') { if (typeof data.productId === 'number') {
await this.ensureProductExists(data.productId); await this.ensureProductExists(data.productId, userId);
} }
const updateData: Prisma.InventoryItemUpdateInput = {}; const updateData: Prisma.InventoryItemUpdateInput = {};
@@ -241,8 +246,8 @@ export class InventoryService {
}); });
} }
async remove(id: number) { async remove(id: number, userId: number) {
await this.findInventoryItemByIdOrThrow(id); await this.findInventoryItemByIdOrThrow(id, userId);
return this.prisma.inventoryItem.delete({ where: { id } }); return this.prisma.inventoryItem.delete({ where: { id } });
} }
} }