feat: implement user-specific inventory management with security checks
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user