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
@@ -74,6 +74,20 @@ vi.mock('@/components/layout/app-version-badge', () => ({
AppVersionBadge: () => <div data-testid="app-version-badge" />,
}));
// Eigene Module (quick-260929-9wc): eigener Client, Standard = leere Liste,
// damit die Abruf-Zaehler-Tests auf `fetch` unveraendert gelten.
let mockCustomModules: {
id: string;
name: string;
url: string;
category: string;
createdAt: string;
updatedAt: string;
}[] = [];
vi.mock('@/lib/custom-modules-api', () => ({
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
}));
const mockActiveModules = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
@@ -93,8 +107,21 @@ afterEach(() => {
vi.restoreAllMocks();
mockPathname = '/';
mockRefreshKey = 0;
mockCustomModules = [];
});
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
return {
id: 'cm-1',
name: 'Wiki',
url: 'https://wiki.example.com',
category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z',
updatedAt: '2026-09-29T00:00:00.000Z',
...overrides,
};
}
async function importSidebar() {
const mod = await import('./sidebar');
return mod.Sidebar;
@@ -243,6 +270,85 @@ describe('Sidebar', () => {
});
});
it('zeigt ein eigenes Modul unter seiner Kategorie als Link auf /modules/custom/<id>', async () => {
mockCustomModules = [customModule()];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
const link = screen.getByText('Wiki').closest('a');
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
expect(link?.querySelector('[data-module-tile="custom"]')).not.toBeNull();
// Kategorie-Kopf "infrastructure" (Test-Attrappe ohne Uebersetzung -> Kennung)
const header = screen.getByText('infrastructure').closest('li');
expect(header?.contains(link)).toBe(true);
});
it('zeigt eine Kategorie, die nur eigene Module hat', async () => {
mockCustomModules = [customModule({ category: 'fleet' })];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('fleet')).toBeInTheDocument();
});
expect(screen.getByText('Wiki')).toBeInTheDocument();
// Die eingebauten Kategorien bleiben daneben stehen.
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
});
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Aaa Eigenes')).toBeInTheDocument();
});
const items = screen
.getByText('Domain-Tools')
.closest('li')
?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
});
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
mockCustomModules = [customModule(), customModule({ id: 'cm-2', name: 'Anderes' })];
mockPathname = '/modules/custom/cm-1';
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
expect(
screen.getByText('Wiki').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).not.toBeNull();
expect(
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull();
expect(
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull();
});
it('die Suche findet ein eigenes Modul', async () => {
mockCustomModules = [customModule()];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
await userEvent.type(screen.getByPlaceholderText('Module suchen...'), 'wik');
await waitFor(() => {
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
});
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('renders the version badge below the navigation', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);