Files
tessera-ctl/apps/api/src/custom-modules/custom-modules.controller.spec.ts
T
schalli c703d87a1c feat(260929-dzu): persoenliche eigene Module je Benutzer (API, Migration, Zeilenschutz)
- ownerUserId (NULL = gemeinsam, sonst persoenlich) mit Zeilenschutz nach Muster SearchProvider
- GET nur gemeinsame + eigene, fremde persoenliche Eintraege 404
- POST fuer jeden Benutzer, shared nur fuer Administratoren (403)
- PATCH/DELETE: persoenlich nur Besitzer, gemeinsam nur Administrator
- Zugriffsklassifikation nachgemessen: 61/223/6

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:09:08 +02:00

108 lines
4.7 KiB
TypeScript

import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CustomModulesController } from './custom-modules.controller';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
function makeService() {
return {
list: vi.fn(async (..._args: unknown[]) => []),
getOne: vi.fn(async (..._args: unknown[]) => ({})),
create: vi.fn(async (..._args: unknown[]) => ({})),
update: vi.fn(async (..._args: unknown[]) => ({})),
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
};
}
const req = (tenantId?: string) => ({ tenantId }) as any;
const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = CustomModulesController.prototype as any;
describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
// Jeder Angemeldete darf persoenliche Eintraege anlegen/aendern/loeschen; die
// Administrator-Pflicht fuer gemeinsame Eintraege prueft der Dienst (hangt
// vom Eintrag ab, nicht von der Route) — siehe custom-modules.service.spec.ts.
it.each([
'list',
'getOne',
'create',
'update',
'remove',
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
});
it('haengt an Pfad custom-modules', () => {
expect(Reflect.getMetadata('path', CustomModulesController)).toBe('custom-modules');
});
});
describe('CustomModulesController — Mandant', () => {
it('reicht req.tenantId an den Dienst weiter', async () => {
const service = makeService();
const controller = new CustomModulesController(service as any);
await controller.list(req('t1'), user);
await controller.getOne(req('t1'), user, 'x');
await controller.create(req('t1'), user, { name: 'a', url: 'https://a.de', category: 'fleet' });
await controller.update(req('t1'), user, 'x', { name: 'b' });
await controller.remove(req('t1'), user, 'x');
expect(service.list).toHaveBeenCalledWith('t1', user);
expect(service.getOne).toHaveBeenCalledWith('t1', user, 'x');
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', user]);
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', user, 'x']);
expect(service.remove).toHaveBeenCalledWith('t1', user, 'x');
});
it('wirft ForbiddenException ohne req.tenantId', async () => {
const controller = new CustomModulesController(makeService() as any);
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getOne(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.create(req(), user, { name: 'a', url: 'https://a.de', category: 'fleet' }),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
});
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'a', url: 'https://a.de', category: 'fleet', tenantId: 'evil' },
{ type: 'body', metatype: CreateCustomModuleDto },
);
expect(out).not.toHaveProperty('tenantId');
});
it('die globale Pipe verwirft ownerUserId, laesst shared beim Anlegen durch', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'a', url: 'https://a.de', category: 'fleet', ownerUserId: 'evil', shared: true },
{ type: 'body', metatype: CreateCustomModuleDto },
);
expect(out).not.toHaveProperty('ownerUserId');
expect(out.shared).toBe(true);
});
it('die globale Pipe verwirft shared und ownerUserId beim Aendern', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'b', shared: true, ownerUserId: 'evil' },
{ type: 'body', metatype: UpdateCustomModuleDto },
);
expect(out).not.toHaveProperty('shared');
expect(out).not.toHaveProperty('ownerUserId');
expect(out.name).toBe('b');
});
});
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
it('deklariert list vor getOne', () => {
const methods = Object.getOwnPropertyNames(CustomModulesController.prototype);
const listIdx = methods.indexOf('list');
const idIdx = methods.indexOf('getOne');
expect(listIdx).toBeGreaterThanOrEqual(0);
expect(idIdx).toBeGreaterThanOrEqual(0);
expect(listIdx).toBeLessThan(idIdx);
});
});