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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<string, any>();
|
||||
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']);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) {}
|
||||
Reference in New Issue
Block a user