feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite

- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000
- API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten
- Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“
- MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:25:33 +02:00
parent 643b1a2caa
commit b9d87be360
23 changed files with 1270 additions and 40 deletions
@@ -0,0 +1,81 @@
import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CustomModulesController } from './custom-modules.controller';
import { CreateCustomModuleDto } 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 proto = CustomModulesController.prototype as any;
describe('CustomModulesController — Rollen (T-9WC-01)', () => {
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
});
it.each(['list', 'getOne'])('%s traegt keine Rollen (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'));
await controller.getOne(req('t1'), 'x');
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' });
await controller.update(req('t1'), 'x', { name: 'b' });
await controller.remove(req('t1'), 'x');
expect(service.list).toHaveBeenCalledWith('t1');
expect(service.getOne).toHaveBeenCalledWith('t1', 'x');
expect(service.create.mock.calls[0][0]).toBe('t1');
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']);
expect(service.remove).toHaveBeenCalledWith('t1', 'x');
});
it('wirft ForbiddenException ohne req.tenantId', async () => {
const controller = new CustomModulesController(makeService() as any);
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.create(req(), { name: 'a', url: 'https://a.de', category: 'fleet' }),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.remove(req(), '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');
});
});
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);
});
});