diff --git a/apps/api/prisma/migrations/20260929120000_custom_module/migration.sql b/apps/api/prisma/migrations/20260929120000_custom_module/migration.sql new file mode 100644 index 0000000..b03dd67 --- /dev/null +++ b/apps/api/prisma/migrations/20260929120000_custom_module/migration.sql @@ -0,0 +1,47 @@ +-- 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Eintraege. +-- +-- Zweck: neue Tabelle "CustomModule". Der Administrator legt Eintraege an +-- (Name, https-Adresse, Kategorie), alle Benutzer des Mandanten sehen sie in +-- der Seitenleiste und ein Klick zeigt die Seite im Rahmen. Mehrere Zeilen je +-- Mandant, Vorbild "ProxmoxServer" (tenantId-Spalte, keine Relation zu +-- Tenant). +-- +-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand +-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt. +-- +-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die +-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE +-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — eigene +-- Module sind Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines +-- einzelnen Benutzers. +-- +-- BEWUSST KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der +-- eigene Module ueber alle Mandanten lesen muesste; jeder Zugriff laeuft +-- mandantengebunden ueber `forTenant(prisma, tenantId)`. +-- +-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber +-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu +-- tun. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter +-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md). + +CREATE TABLE "CustomModule" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "category" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CustomModule_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "CustomModule_tenantId_idx" ON "CustomModule"("tenantId"); + +ALTER TABLE "CustomModule" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "CustomModule" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "CustomModule" + USING ("tenantId" = current_tenant_id()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index bc41987..c464294 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -716,3 +716,19 @@ model ProxmoxServerStatus { @@index([tenantId]) } + +// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten- +// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle +// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId, +// keine Relation zu Tenant). +model CustomModule { + id String @id @default(uuid()) + tenantId String + name String + url String + category String // eine der MODULE_CATEGORIES aus @tessera/shared + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tenantId]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 99339a0..360be30 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -27,6 +27,7 @@ import { TenantModule } from './tenant/tenant.module'; import { TendersModule } from './tenders/tenders.module'; import { UserModule } from './user/user.module'; import { ProxmoxModule } from './proxmox/proxmox.module'; +import { CustomModulesModule } from './custom-modules/custom-modules.module'; @Module({ imports: [ @@ -53,6 +54,7 @@ import { ProxmoxModule } from './proxmox/proxmox.module'; TendersModule, BugReportsModule, ProxmoxModule, + CustomModulesModule, ], providers: [ // Global JWT guard: all routes require auth unless @Public() diff --git a/apps/api/src/custom-modules/custom-modules.controller.spec.ts b/apps/api/src/custom-modules/custom-modules.controller.spec.ts new file mode 100644 index 0000000..23c4201 --- /dev/null +++ b/apps/api/src/custom-modules/custom-modules.controller.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/custom-modules/custom-modules.controller.ts b/apps/api/src/custom-modules/custom-modules.controller.ts new file mode 100644 index 0000000..f788130 --- /dev/null +++ b/apps/api/src/custom-modules/custom-modules.controller.ts @@ -0,0 +1,73 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + Patch, + Post, + Req, +} from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { Roles } from '../auth/decorators/roles.decorator'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; +import { CustomModulesService } from './custom-modules.service'; +import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto'; + +/** + * Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem + * angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01); + * Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`: + * eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt + * ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`). + * + * ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab. + * Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`) + * stehen, sonst faengt `:id` sie ab (404-Shadowing); der Controller-Test + * haelt die Reihenfolge von `list` vor `getOne` fest. + */ +@Controller('custom-modules') +export class CustomModulesController { + constructor(private readonly service: CustomModulesService) {} + + private requireTenantId(req: AuthenticatedRequest): string { + const tenantId = req.tenantId; + if (!tenantId) { + throw new ForbiddenException('Kein Mandantenkontext'); + } + return tenantId; + } + + @Get() + async list(@Req() req: AuthenticatedRequest) { + return this.service.list(this.requireTenantId(req)); + } + + @Get(':id') + async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return this.service.getOne(this.requireTenantId(req), id); + } + + @Post() + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) { + return this.service.create(this.requireTenantId(req), dto); + } + + @Patch(':id') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async update( + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Body() dto: UpdateCustomModuleDto, + ) { + return this.service.update(this.requireTenantId(req), id, dto); + } + + @Delete(':id') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return this.service.remove(this.requireTenantId(req), id); + } +} diff --git a/apps/api/src/custom-modules/custom-modules.module.ts b/apps/api/src/custom-modules/custom-modules.module.ts new file mode 100644 index 0000000..b1fe6d0 --- /dev/null +++ b/apps/api/src/custom-modules/custom-modules.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { CustomModulesController } from './custom-modules.controller'; +import { CustomModulesService } from './custom-modules.service'; + +/** + * Eigene Module (quick-260929-9wc). `PrismaModule` ist global (wie bei + * `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt). + */ +@Module({ + controllers: [CustomModulesController], + providers: [CustomModulesService], +}) +export class CustomModulesModule {} diff --git a/apps/api/src/custom-modules/custom-modules.service.spec.ts b/apps/api/src/custom-modules/custom-modules.service.spec.ts new file mode 100644 index 0000000..3a0ced8 --- /dev/null +++ b/apps/api/src/custom-modules/custom-modules.service.spec.ts @@ -0,0 +1,126 @@ +import { NotFoundException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; + +// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft +// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId) +// uebergeben wird. +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((p: unknown) => p), +})); + +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { CustomModulesService } from './custom-modules.service'; + +function makeFakePrisma() { + const rows = new Map(); + let seq = 0; + const customModule = { + create: vi.fn(async ({ data }: { data: any }) => { + const id = `cm-${++seq}`; + const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data }; + rows.set(id, row); + return row; + }), + findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => { + let list = [...rows.values()]; + if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId); + if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name)); + return list; + }), + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.get(where.id) ?? null), + update: vi.fn(async ({ where, data }: { where: { id: string }; data: any }) => { + const row = { ...rows.get(where.id), ...data }; + rows.set(where.id, row); + return row; + }), + delete: vi.fn(async ({ where }: { where: { id: string } }) => { + rows.delete(where.id); + }), + }; + return { customModule, rows }; +} + +const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const }; + +describe('CustomModulesService', () => { + it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => { + const prisma = makeFakePrisma(); + const service = new CustomModulesService(prisma as any); + await service.create('t1', { ...dto, tenantId: 'evil' } as any); + expect(prisma.customModule.create).toHaveBeenCalledTimes(1); + expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1'); + }); + + it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => { + const prisma = makeFakePrisma(); + const service = new CustomModulesService(prisma as any); + await service.create('t1', { ...dto, name: 'Zebra' }); + await service.create('t1', { ...dto, name: 'Anker' }); + await service.create('t2', { ...dto, name: 'Fremd' }); + const result = await service.list('t1'); + expect(result.map((r: any) => r.name)).toEqual(['Anker', 'Zebra']); + expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({ tenantId: 't1' }); + }); + + it('getOne liefert die Zeile ohne tenantId', async () => { + const prisma = makeFakePrisma(); + const service = new CustomModulesService(prisma as any); + const created: any = await service.create('t1', dto); + const row: any = await service.getOne('t1', created.id); + expect(row.name).toBe('Wiki'); + expect(row).not.toHaveProperty('tenantId'); + }); + + it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => { + const service = new CustomModulesService(makeFakePrisma() as any); + await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException); + await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf( + NotFoundException, + ); + await expect(service.remove('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => { + const prisma = makeFakePrisma(); + const service = new CustomModulesService(prisma as any); + const created: any = await service.create('t2', dto); + await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException); + await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf( + NotFoundException, + ); + await expect(service.remove('t1', created.id)).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.customModule.update).not.toHaveBeenCalled(); + expect(prisma.customModule.delete).not.toHaveBeenCalled(); + }); + + it('update aendert nur gesetzte Felder', async () => { + const prisma = makeFakePrisma(); + const service = new CustomModulesService(prisma as any); + const created: any = await service.create('t1', dto); + await service.update('t1', created.id, { name: 'Neu' }); + expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' }); + }); + + it('remove loescht und liefert { deleted: true }', async () => { + const prisma = makeFakePrisma(); + const service = new CustomModulesService(prisma as any); + const created: any = await service.create('t1', dto); + await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true }); + expect(prisma.rows.size).toBe(0); + }); + + it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => { + const prisma = makeFakePrisma(); + const service = new CustomModulesService(prisma as any); + vi.mocked(forTenant).mockClear(); + const created: any = await service.create('t1', dto); + await service.list('t1'); + await service.getOne('t1', created.id); + await service.update('t1', created.id, { name: 'a' }); + await service.remove('t1', created.id); + expect(forTenant).toHaveBeenCalledTimes(5); + for (const call of vi.mocked(forTenant).mock.calls) { + expect(call).toEqual([prisma, 't1']); + } + }); +}); diff --git a/apps/api/src/custom-modules/custom-modules.service.ts b/apps/api/src/custom-modules/custom-modules.service.ts new file mode 100644 index 0000000..aa0669c --- /dev/null +++ b/apps/api/src/custom-modules/custom-modules.service.ts @@ -0,0 +1,85 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; +import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto'; + +/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */ +const CUSTOM_MODULE_SELECT = { + id: true, + name: true, + url: true, + category: true, + createdAt: true, + updatedAt: true, +}; + +/** + * Eigene Module (quick-260929-9wc): vom Administrator angelegte + * Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt + * ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je + * Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen + * getOne/update/remove `row.tenantId` — zweites Netz, solange der + * RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte + * id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz. + */ +@Injectable() +export class CustomModulesService { + constructor(private readonly prisma: PrismaService) {} + + async list(tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); + return tenantPrisma.customModule.findMany({ + where: { tenantId }, + orderBy: { name: 'asc' }, + select: CUSTOM_MODULE_SELECT, + }); + } + + async getOne(tenantId: string, id: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const row = await tenantPrisma.customModule.findUnique({ + where: { id }, + select: { ...CUSTOM_MODULE_SELECT, tenantId: true }, + }); + if (!row || row.tenantId !== tenantId) { + throw new NotFoundException('Eigenes Modul nicht gefunden'); + } + const { tenantId: _omit, ...result } = row; + return result; + } + + async create(tenantId: string, dto: CreateCustomModuleDto) { + const tenantPrisma = forTenant(this.prisma, tenantId); + return tenantPrisma.customModule.create({ + data: { tenantId, name: dto.name, url: dto.url, category: dto.category }, + select: CUSTOM_MODULE_SELECT, + }); + } + + async update(tenantId: string, id: string, dto: UpdateCustomModuleDto) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const existing = await tenantPrisma.customModule.findUnique({ where: { id } }); + if (!existing || existing.tenantId !== tenantId) { + throw new NotFoundException('Eigenes Modul nicht gefunden'); + } + const data: { name?: string; url?: string; category?: string } = {}; + if (dto.name !== undefined) data.name = dto.name; + if (dto.url !== undefined) data.url = dto.url; + if (dto.category !== undefined) data.category = dto.category; + return tenantPrisma.customModule.update({ + where: { id }, + data, + select: CUSTOM_MODULE_SELECT, + }); + } + + async remove(tenantId: string, id: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const existing = await tenantPrisma.customModule.findUnique({ where: { id } }); + if (!existing || existing.tenantId !== tenantId) { + throw new NotFoundException('Eigenes Modul nicht gefunden'); + } + await tenantPrisma.customModule.delete({ where: { id } }); + return { deleted: true }; + } +} diff --git a/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts b/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts new file mode 100644 index 0000000..04a1ab1 --- /dev/null +++ b/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts @@ -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(cls: new () => T, plain: Record) { + 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'); + }); +}); diff --git a/apps/api/src/custom-modules/dto/custom-module.dto.ts b/apps/api/src/custom-modules/dto/custom-module.dto.ts new file mode 100644 index 0000000..7f1b754 --- /dev/null +++ b/apps/api/src/custom-modules/dto/custom-module.dto.ts @@ -0,0 +1,66 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { MODULE_CATEGORIES } from '@tessera/shared'; +import { Transform } from 'class-transformer'; +import { + IsIn, + IsNotEmpty, + IsString, + MaxLength, + Validate, + ValidatorConstraint, + type ValidatorConstraintInterface, +} from 'class-validator'; + +/** + * Adresse eines eigenen Moduls (T-9WC-03, T-9WC-06): gueltig nur, wenn der + * URL-Parser sie annimmt, das Schema `https:` ist, ein Rechnername da ist und + * weder Benutzername noch Kennwort in der Adresse stehen — sonst saehe jeder + * Benutzer die Zugangsdaten. `javascript:`, `data:`, `http:` und `ftp:` fallen + * damit heraus. + */ +@ValidatorConstraint({ name: 'nurHttpsOhneZugangsdaten', async: false }) +class NurHttpsOhneZugangsdatenConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== 'string') return false; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + return ( + parsed.protocol === 'https:' && + parsed.hostname !== '' && + parsed.username === '' && + parsed.password === '' + ); + } + + defaultMessage(): string { + return 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'; + } +} + +const trimString = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +/** DTO fuer das Anlegen eines eigenen Moduls. */ +export class CreateCustomModuleDto { + @Transform(trimString) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name!: string; + + @Transform(trimString) + @IsString() + @MaxLength(2048) + @Validate(NurHttpsOhneZugangsdatenConstraint) + url!: string; + + @IsIn([...MODULE_CATEGORIES]) + category!: (typeof MODULE_CATEGORIES)[number]; +} + +/** Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. */ +export class UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto) {} diff --git a/apps/web/src/app/(portal)/modules/custom/[id]/page.tsx b/apps/web/src/app/(portal)/modules/custom/[id]/page.tsx new file mode 100644 index 0000000..4c41e1a --- /dev/null +++ b/apps/web/src/app/(portal)/modules/custom/[id]/page.tsx @@ -0,0 +1,16 @@ +import { CustomModuleView } from '@/components/modules/custom-module-view'; + +interface CustomModulePageProps { + params: Promise<{ id: string }>; +} + +/** + * Eigenes Modul (quick-260929-9wc): externe https-Seite im Rahmen. + * Bewusst OHNE ModuleAccessGate — eigene Module sind fuer alle angemeldeten + * Benutzer sichtbar (D-01); der statische Ordner `custom` hat im App Router + * Vorrang vor `[category]/[moduleSlug]`. + */ +export default async function CustomModulePage({ params }: CustomModulePageProps) { + const { id } = await params; + return ; +} diff --git a/apps/web/src/components/layout/sidebar.test.tsx b/apps/web/src/components/layout/sidebar.test.tsx index 6de459d..aed5b47 100644 --- a/apps/web/src/components/layout/sidebar.test.tsx +++ b/apps/web/src/components/layout/sidebar.test.tsx @@ -74,6 +74,20 @@ vi.mock('@/components/layout/app-version-badge', () => ({ AppVersionBadge: () =>
, })); +// 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/', async () => { + mockCustomModules = [customModule()]; + const Sidebar = await importSidebar(); + render(); + + 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(); + + 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(); + + 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/ 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(); + + 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(); + 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(); diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index efbe6ea..4ce3a0e 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -13,6 +13,7 @@ import { ModuleTile } from '@/components/modules/module-tile'; import { useCategoryLabel } from '@/lib/use-category-label'; import { useNavStore } from '@/lib/stores/nav-store'; import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting'; +import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; /** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */ function SelectionPill() { @@ -34,11 +35,50 @@ interface SidebarModule { category: string; } +/** + * Ein Eintrag der Seitenleiste: eingebautes Modul ODER eigenes Modul + * (quick-260929-9wc). Beide laufen durch dieselbe Gruppierung, Suche und + * Kachelliste; sie unterscheiden sich nur in Ziel und Kachelsymbol. + */ +interface SidebarEntry { + key: string; + name: string; + category: string; + href: string; + /** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */ + tileSlug: string; + /** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */ + extraActiveHref?: string; +} + +function moduleEntry(mod: SidebarModule): SidebarEntry { + const category = mod.category || 'other'; + return { + key: mod.id, + name: mod.name, + category, + href: `/modules/${category}/${mod.slug}`, + tileSlug: mod.slug, + extraActiveHref: `/modules/${mod.slug}`, + }; +} + +function customEntry(mod: CustomModule): SidebarEntry { + return { + key: `custom-${mod.id}`, + name: mod.name, + category: mod.category || 'other', + href: `/modules/custom/${mod.id}`, + tileSlug: 'custom', + }; +} + export function Sidebar() { const t = useTranslations('sidebar'); const tCommon = useTranslations('common'); const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore(); const [activeModules, setActiveModules] = useState([]); + const [customModules, setCustomModules] = useState([]); const [searchQuery, setSearchQuery] = useState(''); // Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln // sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN. @@ -67,6 +107,9 @@ export function Sidebar() { const linkClass = (href: string) => itemClass(isActive(href)); + const isEntryActive = (entry: SidebarEntry) => + isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref)); + // Moduleintraege unter einer Kategorie eine Stufe kleiner als die // Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift; // die 20-px-Modulkachel passt weiter hinein. @@ -74,19 +117,50 @@ export function Sidebar() { itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]'); const fetchActiveModules = useCallback(async () => { - try { - const res = await fetch(`${API_URL}/modules/active`, { - credentials: 'include', - }); - if (res.ok) { - const modules: SidebarModule[] = await res.json(); - setActiveModules(modules); - setNavModules(modules); + const loadBuiltIn = async () => { + try { + const res = await fetch(`${API_URL}/modules/active`, { + credentials: 'include', + }); + if (res.ok) { + setActiveModules(await res.json()); + } + } catch { + // silently fail } - } catch { - // silently fail - } - }, [setNavModules]); + }; + // Eigene Module (quick-260929-9wc) im selben Auffrisch-Takt; ein Fehler + // laesst die Leiste wie beim Modulabruf still beim bisherigen Stand. + const loadCustom = async () => { + try { + setCustomModules(await listCustomModules()); + } catch { + // silently fail + } + }; + await Promise.all([loadBuiltIn(), loadCustom()]); + }, []); + + // Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene + // Module gehen mit slug = id hinein, damit `resolvePageTitle` das + // Pfadsegment /modules/custom/ ohne Aenderung findet. + useEffect(() => { + setNavModules([ + ...activeModules, + ...customModules.map((m) => ({ + id: m.id, + slug: m.id, + name: m.name, + category: m.category, + })), + ]); + }, [activeModules, customModules, setNavModules]); + + // Eingebaute Module stehen innerhalb einer Kategorie vor eigenen. + const entries = useMemo( + () => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)], + [activeModules, customModules], + ); // Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch- // Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch @@ -107,17 +181,16 @@ export function Sidebar() { }; const filteredCategories = useMemo(() => { - const categories = new Map(); - for (const mod of activeModules) { - const cat = mod.category || 'other'; - if (!categories.has(cat)) categories.set(cat, []); - categories.get(cat)!.push(mod); + const categories = new Map(); + for (const entry of entries) { + if (!categories.has(entry.category)) categories.set(entry.category, []); + categories.get(entry.category)!.push(entry); } if (!searchQuery) return Array.from(categories.entries()); const q = searchQuery.toLowerCase(); - const result: [string, SidebarModule[]][] = []; + const result: [string, SidebarEntry[]][] = []; for (const [cat, mods] of categories.entries()) { if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) { result.push([cat, mods]); @@ -127,7 +200,7 @@ export function Sidebar() { } } return result; - }, [activeModules, searchQuery, categoryLabel]); + }, [entries, searchQuery, categoryLabel]); const navIcon = (children: React.ReactNode) => (
    - {activeModules.map((mod) => { - const href = `/modules/${mod.category || 'other'}/${mod.slug}`; - const active = isActive(href) || isActive(`/modules/${mod.slug}`); + {entries.map((entry) => { + const active = isEntryActive(entry); return ( -
  • - +
  • + {active && } - +
  • ); @@ -192,7 +264,7 @@ export function Sidebar() {
- {activeModules.length === 0 ? ( + {entries.length === 0 ? (
{t('categories.empty')}
@@ -202,7 +274,7 @@ export function Sidebar() {
) : (
    - {filteredCategories.map(([category, mods]) => { + {filteredCategories.map(([category, categoryEntries]) => { // Bei aktiver Suche immer aufgeklappt, sonst nach Wahl. const open = searchQuery !== '' || !closedCategories.has(category); return ( @@ -220,15 +292,14 @@ export function Sidebar() { {open && (
      - {mods.map((mod) => { - const href = `/modules/${category}/${mod.slug}`; - const active = isActive(href) || isActive(`/modules/${mod.slug}`); + {categoryEntries.map((entry) => { + const active = isEntryActive(entry); return ( -
    • - +
    • + {active && } - - {mod.name} + + {entry.name}
    • ); diff --git a/apps/web/src/components/modules/custom-module-view.test.tsx b/apps/web/src/components/modules/custom-module-view.test.tsx new file mode 100644 index 0000000..b0721e1 --- /dev/null +++ b/apps/web/src/components/modules/custom-module-view.test.tsx @@ -0,0 +1,96 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => { + const map: Record = { + openInNewTab: 'In neuem Tab öffnen', + embedHint: 'Manche Seiten lassen sich nicht einbetten.', + notFound: 'Dieses Modul gibt es nicht mehr.', + invalidUrl: 'Ungültige Adresse', + loading: 'Wird geladen', + }; + return map[key] ?? key; + }, +})); + +const { mockGetCustomModule } = vi.hoisted(() => ({ mockGetCustomModule: vi.fn() })); +vi.mock('@/lib/custom-modules-api', async () => { + const actual = await vi.importActual( + '@/lib/custom-modules-api', + ); + return { ...actual, getCustomModule: mockGetCustomModule }; +}); + +import { CustomModuleView } from './custom-module-view'; + +function mod(url: string) { + return { + id: 'abc', + name: 'Wiki', + url, + category: 'infrastructure', + createdAt: '2026-09-29T00:00:00.000Z', + updatedAt: '2026-09-29T00:00:00.000Z', + }; +} + +beforeEach(() => { + mockGetCustomModule.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('CustomModuleView', () => { + it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => { + mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki')); + render(); + + const frame = await screen.findByTitle('Wiki'); + expect(frame.tagName).toBe('IFRAME'); + expect(frame).toHaveAttribute('src', 'https://example.com/wiki'); + expect(frame.getAttribute('sandbox')).toBe(XFRAME_SANDBOX); + expect(frame.getAttribute('sandbox')).not.toContain('top-navigation'); + expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer'); + expect(frame.getAttribute('allow')).toBe(''); + expect(mockGetCustomModule).toHaveBeenCalledWith('abc'); + }); + + it('zeigt Namen und immer sichtbar den Link „In neuem Tab öffnen“', async () => { + mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki')); + render(); + + const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' }); + expect(link).toHaveAttribute('href', 'https://example.com/wiki'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByRole('heading', { name: 'Wiki' })).toBeInTheDocument(); + }); + + it.each([ + 'http://example.com', + 'javascript:alert(1)', + 'data:text/html,x', + 'https://user:pw@example.com', + ])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => { + mockGetCustomModule.mockResolvedValue(mod(url)); + const { container } = render(); + + await screen.findByText('Ungültige Adresse'); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.querySelector('a')).toBeNull(); + }); + + it('zeigt bei 404 den Nicht-gefunden-Text', async () => { + mockGetCustomModule.mockResolvedValue(null); + const { container } = render(); + + await waitFor(() => { + expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument(); + }); + expect(container.querySelector('iframe')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/modules/custom-module-view.tsx b/apps/web/src/components/modules/custom-module-view.tsx new file mode 100644 index 0000000..6d8d163 --- /dev/null +++ b/apps/web/src/components/modules/custom-module-view.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; +import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api'; + +type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule }; + +/** + * Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): schmale + * Leiste mit Name, Hinweis und dem immer sichtbaren Knopf „In neuem Tab + * oeffnen“, darunter der flaechenfuellende Rahmen. Sandbox-Wert und + * Attribute wie im XFrame-Widget (`XFRAME_SANDBOX`, kein `allow`, kein + * Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne + * Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit + * `javascript:` oder `http:` wird nie gerendert (T-9WC-03). + */ +export function CustomModuleView({ id }: { id: string }) { + const t = useTranslations('customModules'); + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + setState({ status: 'loading' }); + getCustomModule(id) + .then((mod) => { + if (cancelled) return; + setState(mod ? { status: 'ready', mod } : { status: 'missing' }); + }) + .catch(() => { + if (!cancelled) setState({ status: 'missing' }); + }); + return () => { + cancelled = true; + }; + }, [id]); + + if (state.status === 'loading') { + return
      {t('loading')}
      ; + } + if (state.status === 'missing') { + return
      {t('notFound')}
      ; + } + + const { mod } = state; + const urlOk = checkCustomModuleUrl(mod.url) === 'ok'; + + return ( +
      +
      +
      +

      {mod.name}

      + {urlOk &&

      {t('embedHint')}

      } +
      + {urlOk && ( + + {t('openInNewTab')} + + )} +
      + {urlOk ? ( +