fix(nextcloud-files): CR-02 Nextcloud-Adresse aendern nur noch Administratoren

- PUT settings traegt @Roles(ADMIN, SUPER_ADMIN) statt @ModuleManage (Muster wie
  TendersController.getSourceConfig); alle Benutzer schicken ihr Passwort an diese Adresse
- POST settings/test: Verwalter ohne Administratorrolle pruefen nur die gespeicherte Adresse,
  jede andere Adresse ist 403
- Einstellungen fuer Verwalter nur lesbar mit kurzem Hinweis, ohne Speichern-Knopf; Hinweis
  "nicht eingerichtet" verweist Nicht-Administratoren an einen Administrator
- Metadaten-Spec (module-manage-handlers), Controller-Spec, Seitentest, e2e-settings
  (Verwalter: 403 auf PUT und fremde Pruefung), Anleitungen und Changelog angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:31:06 +02:00
parent 5c2d327bef
commit e78e059477
12 changed files with 187 additions and 37 deletions
@@ -136,7 +136,6 @@ describe('Umgestellte Handler (Verwalten)', () => {
it.each([
'getSettings',
'saveSettings',
'testSettings',
])('NextcloudFilesController.%s verlangt Verwalten für nextcloud-files (quick-261008-mzu)', (name) => {
expectManage(NextcloudFilesController, name, 'nextcloud-files');
@@ -201,4 +200,8 @@ describe('Bewusst nur für Administratoren (T-icv-01, T-icv-07)', () => {
])('ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => {
expectAdminOnly(ModuleRegistryController, name);
});
it('NextcloudFilesController.saveSettings: die Nextcloud-Adresse legt nur ein Administrator fest (quick-261008-mzu, CR-02)', () => {
expectAdminOnly(NextcloudFilesController, 'saveSettings');
});
});
@@ -9,7 +9,9 @@ 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'];
const MANAGE_HANDLERS = ['getSettings', 'testSettings'];
/** Nur Administratoren (CR-02): die Adresse aendern. */
const ADMIN_HANDLERS = ['saveSettings'];
/** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */
const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle'];
@@ -52,15 +54,24 @@ describe('NextcloudFilesController — Metadaten', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudFilesController)).toBe('nextcloud-files');
});
it('Einstellungen und Pruefung verlangen Verwalten, ohne Rollen-Decorator', () => {
it('Einstellungen lesen 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('die Adresse aendern duerfen nur Administratoren (CR-02)', () => {
for (const name of ADMIN_HANDLERS) {
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toEqual(['ADMIN', 'SUPER_ADMIN']);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
}
});
it('alle anderen Handler stehen auf Benutzen-Ebene', () => {
const others = routeHandlers().filter((n) => !MANAGE_HANDLERS.includes(n));
const others = routeHandlers().filter(
(n) => !MANAGE_HANDLERS.includes(n) && !ADMIN_HANDLERS.includes(n),
);
expect(others).toContain('getStatus');
for (const name of others) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
@@ -195,6 +206,9 @@ describe('NextcloudFilesController — Delegation', () => {
getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })),
getBaseUrl: vi.fn(
async (..._a: unknown[]): Promise<string | null> => 'https://cloud.example',
),
};
}
@@ -224,7 +238,10 @@ describe('NextcloudFilesController — Delegation', () => {
function makeServerInfo() {
return {
getServerInfo: vi.fn(async (..._a: unknown[]) => ({ host: 'cloud.example', name: 'Nextcloud' })),
getServerInfo: vi.fn(async (..._a: unknown[]) => ({
host: 'cloud.example',
name: 'Nextcloud',
})),
sendLogo: vi.fn(async (..._a: unknown[]) => undefined),
};
}
@@ -368,9 +385,10 @@ describe('NextcloudFilesController — Delegation', () => {
makeServerInfo() as any,
);
await controller.getStatus(userReq('t1', 'u1'));
const admin = { tenantId: 't1', user: { id: 'a1', role: 'ADMIN' } } as any;
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);
await controller.saveSettings(admin, { baseUrl: 'https://x.example' } as any);
await controller.testSettings(admin, { baseUrl: 'https://x.example' } as any);
await controller.connectPassword(userReq('t1', 'u1'), {
loginName: ' anna ',
password: 'geheim',
@@ -391,6 +409,31 @@ describe('NextcloudFilesController — Delegation', () => {
expect(account.disconnect).toHaveBeenCalledWith('t1', 'u1');
});
it('Verwalter ohne Administratorrolle pruefen nur die gespeicherte Adresse (CR-02)', async () => {
const settings = makeSettings();
const controller = new NextcloudFilesController(
settings as any,
makeAccount() as any,
makeFiles() as any,
makeTransfer() as any,
makeServerInfo() as any,
);
const manager = { tenantId: 't1', user: { id: 'm1', role: 'USER' } } as any;
await controller.testSettings(manager, { baseUrl: 'https://CLOUD.example/' } as any);
expect(settings.testAddress).toHaveBeenCalledTimes(1);
await expect(
controller.testSettings(manager, { baseUrl: 'http://10.0.0.5:8080' } as any),
).rejects.toBeInstanceOf(ForbiddenException);
settings.getBaseUrl.mockResolvedValueOnce(null);
await expect(
controller.testSettings(manager, { baseUrl: 'https://cloud.example' } as any),
).rejects.toBeInstanceOf(ForbiddenException);
expect(settings.testAddress).toHaveBeenCalledTimes(1);
const superAdmin = { tenantId: 't1', user: { id: 's1', role: 'SUPER_ADMIN' } } as any;
await controller.testSettings(superAdmin, { baseUrl: 'http://10.0.0.5:8080' } as any);
expect(settings.testAddress).toHaveBeenCalledTimes(2);
});
it('ohne Mandantenkontext: ForbiddenException', async () => {
const controller = new NextcloudFilesController(
makeSettings() as any,
@@ -13,9 +13,12 @@ import {
Req,
Res,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import type { Response } from 'express';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { normalizeCloudUrl } from '../nextcloud-status/nextcloud-status-fetch';
import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto';
import {
CreateFolderDto,
@@ -49,9 +52,14 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
* die Benutzerkennung (spaetere Aufgaben) ausschliesslich aus dem Token.
*
* Rechte je Route (quick-261008-mzu, D-N):
* Nur Administratoren (`@Roles(ADMIN, SUPER_ADMIN)`, CR-02): PUT settings. Die
* Adresse bestimmt, wohin ALLE Benutzer ihr Nextcloud-Passwort
* schicken (oft das Verzeichnis-Passwort); das darf keine nur
* delegierte Freigabe.
* Verwalten (`@ModuleManage('nextcloud-files')`, Administratoren und Benutzer
* mit der Freigabestufe Verwalten): GET settings, PUT settings,
* POST settings/test.
* mit der Freigabestufe Verwalten): GET settings, POST settings/test —
* wer nicht Administrator ist, darf dabei nur die GESPEICHERTE
* Adresse pruefen (keine neue, kein Abtasten anderer Adressen).
* Benutzen (nur Klassen-`@UseModule`): alles andere — GET status, GET server,
* GET server/logo, POST connect/password, POST connect/flow,
* DELETE connect, GET files, DELETE files, POST folders, POST move,
@@ -61,7 +69,8 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
* uploads/:uploadId. Jeder Benutzer arbeitet nur im eigenen Konto.
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
* RolesGuard wuerde Verwalter sonst aussperren.
* RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler
* traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`).
*
* REIHENFOLGE: alle statischen Routen stehen VOR jeder Route mit `:param`, sonst
* faengt die Parameterroute sie ab (404-Shadowing, Unit-Tests ohne die
@@ -78,6 +87,14 @@ function headerOf(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
const ADDRESS_ADMIN_ONLY =
'Nur Administratoren dürfen die Nextcloud-Adresse ändern oder eine andere Adresse prüfen.';
function isAdmin(req: AuthenticatedRequest): boolean {
const role = req.user?.role;
return role === Role.ADMIN || role === Role.SUPER_ADMIN;
}
@Controller('modules/nextcloud-files')
@UseModule('nextcloud-files')
export class NextcloudFilesController {
@@ -129,8 +146,9 @@ export class NextcloudFilesController {
return this.settings.getSettings(this.requireTenantId(req));
}
/** Die Adresse legt nur ein Administrator fest (CR-02). */
@Put('settings')
@ModuleManage('nextcloud-files')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: SaveNextcloudFilesSettingsDto) {
return this.settings.saveSettings(this.requireTenantId(req), dto);
}
@@ -139,7 +157,14 @@ export class NextcloudFilesController {
@HttpCode(200)
@ModuleManage('nextcloud-files')
async testSettings(@Req() req: AuthenticatedRequest, @Body() dto: TestNextcloudFilesSettingsDto) {
this.requireTenantId(req);
const tenantId = this.requireTenantId(req);
if (!isAdmin(req)) {
// Verwalter ohne Administratorrolle pruefen nur die gespeicherte Adresse (CR-02, WR-01).
const saved = await this.settings.getBaseUrl(tenantId);
if (saved === null || normalizeCloudUrl(dto.baseUrl) !== saved) {
throw new ForbiddenException(ADDRESS_ADMIN_ONLY);
}
}
return this.settings.testAddress(dto.baseUrl);
}