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>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user