test(security): add and refactor api security/idor coverage
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:
@@ -1,27 +1,9 @@
|
|||||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
import { InventoryController } from './inventory.controller';
|
import { InventoryController } from './inventory.controller';
|
||||||
|
import { getRolesMetadata, mockHttpContext } from '../test-utils/security-test-helpers';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Inventory admin security', () => {
|
describe('Inventory admin security', () => {
|
||||||
const adminHandlers: Array<[string, Function]> = [
|
const adminHandlers: Array<[string, Function]> = [
|
||||||
@@ -33,9 +15,10 @@ describe('Inventory admin security', () => {
|
|||||||
['previewMergeAdmin', InventoryController.prototype.previewMergeAdmin],
|
['previewMergeAdmin', InventoryController.prototype.previewMergeAdmin],
|
||||||
];
|
];
|
||||||
|
|
||||||
it.each(adminHandlers)('admin-endpoint %s har @Roles("admin") metadata', (_name, handler) => {
|
it('alla admin-endpoints har @Roles("admin") metadata', () => {
|
||||||
const roles = Reflect.getMetadata(ROLES_KEY, handler) as string[] | undefined;
|
for (const [, handler] of adminHandlers) {
|
||||||
expect(roles).toEqual(['admin']);
|
expect(getRolesMetadata(handler)).toEqual(['admin']);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(adminHandlers)('RolesGuard nekar icke-admin (403) på %s', (_name, handler) => {
|
it.each(adminHandlers)('RolesGuard nekar icke-admin (403) på %s', (_name, handler) => {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user