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,67 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it } from 'vitest';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './custom-module.dto';
async function errorsFor<T extends object>(cls: new () => T, plain: Record<string, unknown>) {
const dto = plainToInstance(cls, plain);
const errors = await validate(dto as object);
return errors.map((e) => e.property);
}
const valid = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' };
describe('CreateCustomModuleDto', () => {
it('nimmt einen gueltigen Eintrag an', async () => {
expect(await errorsFor(CreateCustomModuleDto, valid)).toEqual([]);
});
it.each([
'http://example.com',
'javascript:alert(1)',
'data:text/html,x',
'ftp://x',
'kaputt',
'https://user:pw@example.com',
'https://user@example.com',
])('lehnt die Adresse %s ab', async (url) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
});
it('lehnt eine unbekannte Kategorie ab', async () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
'category',
);
});
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
});
it('trimmt den Namen', () => {
const dto = plainToInstance(CreateCustomModuleDto, { ...valid, name: ' Wiki ' });
expect(dto.name).toBe('Wiki');
});
it('lehnt zu lange Namen und Adressen ab', async () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name: 'a'.repeat(101) })).toContain(
'name',
);
const longUrl = `https://example.com/${'a'.repeat(2048)}`;
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url: longUrl })).toContain('url');
});
});
describe('UpdateCustomModuleDto', () => {
it('akzeptiert Teilmengen', async () => {
expect(await errorsFor(UpdateCustomModuleDto, { name: 'Neu' })).toEqual([]);
expect(await errorsFor(UpdateCustomModuleDto, {})).toEqual([]);
});
it('prueft jedes gesetzte Feld gleich', async () => {
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
});
});