Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts
T
schalli 960696745f feat(nextcloud-files): Modul Dateien mit Nextcloud-Adresse, Verbindungsprüfung und gesicherter Verbindungsschicht
- Migration 20261008180000: NextcloudFilesConfig (Organisation) und NextcloudFilesAccount
  (Mandant UND Benutzer per Zeilenschutz, nur verschlüsseltes App-Passwort)
- Transportschicht ncRequest: feste Pfadanfänge, Segmentcodierung, keine Weiterleitungen,
  keine Cookies, Zeitgrenzen; Aufrufsperre pausiert den Ursprung nach jedem 429 und
  sperrt Zugangsschlüssel nach dem ersten 401
- Einstellungen: Adresse speichern/prüfen, Adresswechsel läuft alle Konten ab (mit Bestätigung)
- Controller mit Verwalten nur auf Einstellungen, Seed, Modul, Registrierung in Web
  (Ladefunktion, Symbol folder, Navigation, Layout) und Reiter Einstellungen
- RLS-Inventar fortgeschrieben, Testaufbau (tessera-nc-test) und E2E-Skripte

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 17:41:46 +02:00

121 lines
5.3 KiB
TypeScript

import 'reflect-metadata';
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { NextcloudFilesController } from './nextcloud-files.controller';
const proto = NextcloudFilesController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) as any;
/** Verwalten (Administratoren und Freigabestufe Verwalten). Spaetere Aufgaben ergaenzen nichts hier. */
const MANAGE_HANDLERS = ['getSettings', 'saveSettings', 'testSettings'];
/** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */
function routeHandlers(): string[] {
return Object.getOwnPropertyNames(NextcloudFilesController.prototype).filter(
(n) =>
n !== 'constructor' &&
typeof proto[n] === 'function' &&
Reflect.getMetadata('path', proto[n]) !== undefined,
);
}
/**
* Wiederverwendbar (spaetere Aufgaben fuegen nur Handler hinzu): jeder Handler,
* dessen Pfad ein `:` enthaelt, steht NACH allen statischen Handlern.
*/
function expectParamRoutesLast(controller: { prototype: object }): void {
const p = controller.prototype as any;
const names = Object.getOwnPropertyNames(controller.prototype).filter(
(n) =>
n !== 'constructor' &&
typeof p[n] === 'function' &&
Reflect.getMetadata('path', p[n]) !== undefined,
);
const isParam = (n: string) => String(Reflect.getMetadata('path', p[n])).includes(':');
const firstParam = names.findIndex(isParam);
if (firstParam === -1) return;
names.slice(firstParam).forEach((n) => {
expect(isParam(n), `${n} steht nach einer Parameterroute, ist aber statisch`).toBe(true);
});
}
describe('NextcloudFilesController — Metadaten', () => {
it('haengt an modules/nextcloud-files und traegt @UseModule(nextcloud-files)', () => {
expect(Reflect.getMetadata('path', NextcloudFilesController)).toBe('modules/nextcloud-files');
expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudFilesController)).toBe('nextcloud-files');
});
it('Einstellungen und Pruefung verlangen Verwalten, ohne Rollen-Decorator', () => {
for (const name of MANAGE_HANDLERS) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true);
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
});
it('alle anderen Handler stehen auf Benutzen-Ebene', () => {
const others = routeHandlers().filter((n) => !MANAGE_HANDLERS.includes(n));
expect(others).toContain('getStatus');
for (const name of others) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
});
it('Pfade und Methoden', () => {
const route = (name: string) => [
Reflect.getMetadata('method', proto[name]),
Reflect.getMetadata('path', proto[name]),
];
// RequestMethod: GET 0, POST 1, PUT 2, DELETE 3
expect(route('getStatus')).toEqual([0, 'status']);
expect(route('getSettings')).toEqual([0, 'settings']);
expect(route('saveSettings')).toEqual([2, 'settings']);
expect(route('testSettings')).toEqual([1, 'settings/test']);
});
it('POST settings/test antwortet 200, nicht 201', () => {
expect(Reflect.getMetadata('__httpCode__', proto.testSettings)).toBe(200);
});
});
describe('NextcloudFilesController — Routen-Reihenfolge (statisch vor Parameter)', () => {
it('deklariert jeden Handler mit :-Pfad nach allen statischen Handlern', () => {
expectParamRoutesLast(NextcloudFilesController);
});
});
describe('NextcloudFilesController — Delegation', () => {
function makeSettings() {
return {
getStatus: vi.fn(async (..._a: unknown[]) => ({ configured: true })),
getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })),
};
}
it('reicht den Mandanten aus dem Token weiter, nie aus dem Body', async () => {
const settings = makeSettings();
const controller = new NextcloudFilesController(settings as any);
await controller.getStatus(req('t1'));
await controller.getSettings(req('t1'));
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
await controller.testSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
expect(settings.getStatus).toHaveBeenCalledWith('t1');
expect(settings.getSettings).toHaveBeenCalledWith('t1');
expect(settings.saveSettings).toHaveBeenCalledWith('t1', { baseUrl: 'https://x.example' });
expect(settings.testAddress).toHaveBeenCalledWith('https://x.example');
});
it('ohne Mandantenkontext: ForbiddenException', async () => {
const controller = new NextcloudFilesController(makeSettings() as any);
await expect(controller.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getSettings(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.testSettings(req(undefined), { baseUrl: 'https://x.example' } as any),
).rejects.toBeInstanceOf(ForbiddenException);
});
});