feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung

- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten
- Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden
- Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt
- Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:54:58 +02:00
parent 5ef7b0c6cc
commit 6698ef1a92
23 changed files with 2061 additions and 30 deletions
@@ -7,6 +7,7 @@ import { DkvController } from '../dkv/dkv.controller';
import { ModuleGrantsController } from '../groups/module-grants.controller';
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
import { ProxmoxController } from '../proxmox/proxmox.controller';
import { TendersController } from '../tenders/tenders.controller';
import { ModuleRegistryController } from './module-registry.controller';
@@ -70,6 +71,19 @@ describe('Umgestellte Handler (Verwalten)', () => {
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])(
'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status',
(name) => {
expectManage(NextcloudStatusController, name, 'nextcloud-status');
},
);
it.each(['list', 'logo'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(NextcloudStatusController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { checkLogoUpload, NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
const GIF = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
const WEBP = [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50];
describe('checkLogoUpload', () => {
it.each([
['PNG', PNG, 'image/png'],
['JPEG', JPEG, 'image/jpeg'],
['GIF', GIF, 'image/gif'],
['WebP', WEBP, 'image/webp'],
])('erkennt %s', (_name, bytes, mime) => {
expect(checkLogoUpload(Buffer.from([...bytes, 1, 2, 3]))).toBe(mime);
});
it('lehnt SVG-Text, PDF, leere Dateien und umbenanntes HTML ab', () => {
expect(
checkLogoUpload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
).toBeNull();
expect(checkLogoUpload(Buffer.from('%PDF-1.7 ...'))).toBeNull();
expect(checkLogoUpload(Buffer.alloc(0))).toBeNull();
expect(checkLogoUpload(Buffer.from('<html><script>alert(1)</script></html>'))).toBeNull();
});
it('genau 1 MiB ist erlaubt, ein Byte mehr nicht', () => {
const ok = Buffer.concat([
Buffer.from(PNG),
Buffer.alloc(NEXTCLOUD_LOGO_MAX_BYTES - PNG.length),
]);
expect(ok.length).toBe(NEXTCLOUD_LOGO_MAX_BYTES);
expect(checkLogoUpload(ok)).toBe('image/png');
expect(checkLogoUpload(Buffer.concat([ok, Buffer.from([0])]))).toBeNull();
});
});
@@ -0,0 +1,21 @@
import { type DashboardImageMime, detectImageMime } from '../dashboard/dashboard-image-rules';
/**
* Regeln fuer hochgeladene Cloud-Logos (quick-261002-k67, L-02, D-A).
* Kein Nest, kein Prisma — direkt an den Bytes testbar.
*
* Erlaubt sind PNG, JPEG, GIF und WebP bis 1 MiB. KEIN SVG: ein SVG kann
* Skript tragen und wuerde, direkt im Tab geoeffnet, unter der Adresse von
* Tessera laufen. Der Typ kommt aus den Magic Bytes, nie aus dem vom Browser
* behaupteten `mimetype` oder der Dateiendung (Muster T-PI9-01); der erkannte
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-k67-02).
*/
/** Hoechstgroesse: 1 MiB (multer `limits.fileSize` an der Route + zweites Netz im Dienst). */
export const NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024;
/** Erkannter Bildtyp oder `null`, wenn leer, zu gross oder kein erlaubtes Bildformat. */
export function checkLogoUpload(buffer: Uint8Array): DashboardImageMime | null {
if (buffer.length === 0 || buffer.length > NEXTCLOUD_LOGO_MAX_BYTES) return null;
return detectImageMime(buffer);
}
@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest';
import {
NEXTCLOUD_CRON,
NEXTCLOUD_JOB_NAME,
NextcloudStatusSchedulerService,
} from './nextcloud-status-scheduler.service';
function makeFakeRegistry() {
const jobs = new Map<string, any>();
return {
__jobs: jobs,
addCronJob: vi.fn((name: string, job: any) => {
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
jobs.set(name, job);
}),
};
}
function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: string[] = []) {
const registry = makeFakeRegistry();
const calls: Array<[string, string]> = [];
const service = {
loadAllInstancesForScheduler: vi.fn(async () => rows),
checkInstance: vi.fn(async (tenantId: string, id: string) => {
calls.push([tenantId, id]);
if (failFor.includes(id)) throw new Error(`boom ${id}`);
}),
};
const release = { refresh: vi.fn(async () => undefined) };
const scheduler = new NextcloudStatusSchedulerService(
registry as any,
service as any,
release as any,
);
const logger = (scheduler as any).logger;
const logSpy = vi.spyOn(logger, 'log').mockImplementation(() => undefined);
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
return { registry, service, release, scheduler, calls, logSpy, errorSpy, warnSpy };
}
describe('NextcloudStatusSchedulerService', () => {
it('registriert genau einen stuendlichen Auftrag ohne Datenbankzugriff und startet ihn', async () => {
const { registry, service, release, scheduler } = makeScheduler();
await scheduler.onApplicationBootstrap();
expect(registry.addCronJob).toHaveBeenCalledTimes(1);
expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true);
const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME);
expect(job.cronTime.source).toBe(NEXTCLOUD_CRON);
expect(NEXTCLOUD_CRON).toBe('0 * * * *');
expect(job.isActive ?? job.running).toBeTruthy();
expect(service.loadAllInstancesForScheduler).not.toHaveBeenCalled();
expect(release.refresh).toHaveBeenCalledTimes(1);
job.stop();
});
it('ein Fehler beim Start wird protokolliert und nicht weitergeworfen', async () => {
const { registry, scheduler, errorSpy } = makeScheduler();
registry.addCronJob.mockImplementation(() => {
throw new Error('registry kaputt');
});
await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
});
it('tick prueft jede Cloud an ihren eigenen Mandanten gebunden', async () => {
const rows = [
{ id: 'a1', tenantId: 'tA' },
{ id: 'b1', tenantId: 'tB' },
{ id: 'a2', tenantId: 'tA' },
];
const { scheduler, calls } = makeScheduler(rows);
await scheduler.tick();
expect(calls).toHaveLength(3);
expect(calls).toContainEqual(['tA', 'a1']);
expect(calls).toContainEqual(['tA', 'a2']);
expect(calls).toContainEqual(['tB', 'b1']);
});
it('eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
const rows = [
{ id: 'x', tenantId: 't' },
{ id: 'y', tenantId: 't' },
{ id: 'z', tenantId: 't' },
];
const { scheduler, calls, errorSpy } = makeScheduler(rows, ['x']);
await scheduler.tick();
expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y', 'z']);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('ein Durchlauf, waehrend der vorige noch laeuft, wird uebersprungen', async () => {
const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]);
let release!: () => void;
service.checkInstance.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const first = scheduler.tick();
await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1));
await scheduler.tick();
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalled();
release();
await first;
await scheduler.tick();
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,117 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { NextcloudReleaseService } from './nextcloud-release.service';
import {
CHECK_CONCURRENCY,
NextcloudStatusService,
runWithConcurrency,
} from './nextcloud-status.service';
/**
* CronJob constructor — resolved at runtime via require() because `cron` is
* a transitive dependency of @nestjs/schedule (not a direct api dep under
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
* it as a peer dep. Reuses the exact ProxmoxSchedulerService resolution
* workaround verbatim.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
require('cron').CronJob as new (
cronTime: string,
onTick: () => void,
) => { start(): void };
/** Name des Auftrags in der Registry. */
export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll';
/** Jede volle Stunde (L-08). */
export const NEXTCLOUD_CRON = '0 * * * *';
/**
* NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds
* (quick-261002-k67, L-08, D-C).
*
* Lebenszyklus: `OnApplicationBootstrap`, NICHT `OnModuleInit` — die
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis zum
* zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
*
* Ein einziger globaler Auftrag statt je einem je Mandant: das Intervall ist
* fest (stuendlich), es gibt keine Einstellung je Zeile. Der Auftrag wird
* beim Start OHNE Datenbankzugriff registriert — eine frische Datenbank kann
* daher nie ohne Auftrag enden, auch wenn beim Start noch keine Cloud
* eingetragen ist. Jeder Durchlauf liest Kennung und Mandant aller Clouds
* (ein einziger Systemkontext-Aufruf) und prueft dann jede Cloud an ihren
* eigenen Mandanten gebunden, mit hoechstens vier gleichzeitig. Ein
* Ueberlappungsschutz ueberspringt einen Durchlauf, solange der vorige laeuft.
*/
@Injectable()
export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
private readonly logger = new Logger(NextcloudStatusSchedulerService.name);
private running = false;
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly service: NextcloudStatusService,
private readonly release: NextcloudReleaseService,
) {}
/**
* Registriert und startet den Auftrag und stoesst das Aufwaermen der
* Vergleichsdaten an (ohne zu warten). Ein Fehler wird gefangen und
* protokolliert, nie weitergeworfen — die Anwendung startet trotzdem.
*/
async onApplicationBootstrap(): Promise<void> {
try {
const job = new CronJobClass(NEXTCLOUD_CRON, () => {
this.tick().catch((err) =>
this.logger.error(`Nextcloud poll tick failed: ${(err as Error).message}`),
);
});
// Cast noetig — dasselbe Muster wie ProxmoxSchedulerService.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any);
job.start();
this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`);
void this.release.refresh().catch(() => undefined);
} catch (err) {
this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`);
}
}
/** Ein Durchlauf ueber alle Clouds aller Mandanten. */
async tick(): Promise<void> {
if (this.running) {
this.logger.warn('Nextcloud poll tick skipped — previous run still active');
return;
}
this.running = true;
try {
const rows = await this.service.loadAllInstancesForScheduler();
// Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt.
const byTenant = new Map<string, string[]>();
for (const row of rows) {
const ids = byTenant.get(row.tenantId) ?? [];
ids.push(row.id);
byTenant.set(row.tenantId, ids);
}
const work: { tenantId: string; id: string }[] = [];
for (const [tenantId, ids] of byTenant) {
for (const id of ids) work.push({ tenantId, id });
}
await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => {
try {
await this.service.checkInstance(tenantId, id);
} catch (err) {
this.logger.error(
`Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`,
);
}
});
} finally {
this.running = false;
}
}
}
@@ -15,15 +15,37 @@ describe('NextcloudStatusController Metadaten', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard);
});
it('list bleibt auf Benutzen-Ebene', () => {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.list as object)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto.list as object)).toBeUndefined();
it.each(['list', 'logo'])('%s bleibt auf Benutzen-Ebene', (name) => {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto[name] as object)).toBeUndefined();
});
it.each(['create', 'checkOne'])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => {
it.each([
'create',
'update',
'remove',
'checkAll',
'checkOne',
'uploadLogo',
'removeLogo',
])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => {
const fn = proto[name] as object;
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status');
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('die statische Route POST instances/check steht vor jedem Handler mit :id (kein 404-Shadowing)', () => {
const names = Object.getOwnPropertyNames(NextcloudStatusController.prototype).filter(
(n) => n !== 'constructor' && typeof proto[n] === 'function',
);
const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string;
expect(pathOf('checkAll')).toBe('instances/check');
const checkIndex = names.indexOf('checkAll');
const idHandlers = names.filter((n) => (pathOf(n) ?? '').includes(':id'));
expect(idHandlers.length).toBeGreaterThan(0);
for (const name of idHandlers) {
expect(checkIndex, `checkAll vor ${name}`).toBeLessThan(names.indexOf(name));
}
});
});
@@ -1,7 +1,26 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Post,
Put,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
import {
CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto';
import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
import { NextcloudStatusService } from './nextcloud-status.service';
/**
@@ -9,14 +28,15 @@ import { NextcloudStatusService } from './nextcloud-status.service';
* Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich
* aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query.
*
* Rechte (L-09): Lesen (`GET instances`, Logo-Abruf) steht jedem Benutzer mit
* Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
* Rechte (L-09): Lesen (`GET instances`, `GET instances/:id/logo`) steht jedem
* Benutzer mit Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
* `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der
* Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein
* Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren.
*
* Routenreihenfolge: statische Pfade (`instances/check`) stehen VOR allen
* Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing).
* Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing,
* T-k67-08; die Reihenfolge ist im Controller-Spec festgeschrieben).
*/
@Controller('modules/nextcloud-status')
@UseModule('nextcloud-status')
@@ -42,9 +62,73 @@ export class NextcloudStatusController {
return this.service.createInstance(this.requireTenantId(req), dto);
}
/** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */
@Post('instances/check')
@ModuleManage('nextcloud-status')
async checkAll(@Req() req: AuthenticatedRequest) {
return this.service.checkAllForTenant(this.requireTenantId(req));
}
@Put('instances/:id')
@ModuleManage('nextcloud-status')
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateNextcloudInstanceDto,
) {
return this.service.updateInstance(this.requireTenantId(req), id, dto);
}
@Delete('instances/:id')
@ModuleManage('nextcloud-status')
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const deleted = await this.service.deleteInstance(this.requireTenantId(req), id);
return { deleted };
}
@Post('instances/:id/check')
@ModuleManage('nextcloud-status')
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.checkInstance(this.requireTenantId(req), id);
}
/**
* Logo-Abruf fuer jeden Benutzer mit Modulzugriff (die Kachel laedt es per
* <img>). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private
* Zwischenspeicherung (Adresse traegt clientseitig `?v=<logoVersion>`),
* `nosniff` und eine Sandbox-CSP — Muster `favorites.controller.ts` `getIcon`
* (T-k67-02).
*/
@Get('instances/:id/logo')
async logo(@Req() req: AuthenticatedRequest, @Param('id') id: string, @Res() res: Response) {
const { data, mime } = await this.service.getLogo(this.requireTenantId(req), id);
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'private, max-age=86400');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
res.send(data);
}
/**
* Logo hochladen: Groessengrenze JE ROUTE (multers `LIMIT_FILE_SIZE` wird von
* Nest auf 413 abgebildet); Typ und Besitz pruefen im Dienst.
*/
@Post('instances/:id/logo')
@ModuleManage('nextcloud-status')
@UseInterceptors(
FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } }),
)
async uploadLogo(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@UploadedFile() file?: UploadedFileLike,
) {
return this.service.uploadLogo(this.requireTenantId(req), id, file);
}
@Delete('instances/:id/logo')
@ModuleManage('nextcloud-status')
async removeLogo(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.removeLogo(this.requireTenantId(req), id);
}
}
@@ -5,6 +5,7 @@ import { NextcloudReleaseService } from './nextcloud-release.service';
import { NextcloudStatusController } from './nextcloud-status.controller';
import { seedNextcloudStatusModule } from './nextcloud-status.seed';
import { NextcloudStatusService } from './nextcloud-status.service';
import { NextcloudStatusSchedulerService } from './nextcloud-status-scheduler.service';
/**
* NestJS module for the Nextcloud-Status feature (quick-261002-k67).
@@ -13,7 +14,7 @@ import { NextcloudStatusService } from './nextcloud-status.service';
@Module({
imports: [ModuleRegistryModule],
controllers: [NextcloudStatusController],
providers: [NextcloudStatusService, NextcloudReleaseService],
providers: [NextcloudStatusService, NextcloudReleaseService, NextcloudStatusSchedulerService],
})
export class NextcloudStatusModule implements OnModuleInit {
private readonly logger = new Logger(NextcloudStatusModule.name);
@@ -10,7 +10,7 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
return { ...actual, fetchNextcloudStatus: vi.fn() };
});
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import type { NextcloudReference } from './nextcloud-rating';
import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service';
import { fetchNextcloudStatus } from './nextcloud-status-fetch';
@@ -177,4 +177,199 @@ describe('NextcloudStatusService', () => {
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
describe('Schreibwege (Aufgabe 2)', () => {
const OK_RESULT = {
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
productName: null,
errorKind: null,
errorDetail: null,
};
beforeEach(() => {
prisma.nextcloudInstance.delete = vi.fn();
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
});
prisma.nextcloudInstance.update.mockResolvedValue(makeRow());
vi.mocked(fetchNextcloudStatus).mockResolvedValue(OK_RESULT);
});
it('updateInstance: nur Name -> kein Neuabruf', async () => {
await service.updateInstance('t1', 'i1', { customerName: ' Neu ' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
customerName: 'Neu',
});
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
it('updateInstance: neue Adresse wird normalisiert und neu geprueft, unveraenderte nicht', async () => {
await service.updateInstance('t1', 'i1', { baseUrl: 'https://neu.example.de/index.php/' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
baseUrl: 'https://neu.example.de',
});
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
vi.mocked(fetchNextcloudStatus).mockClear();
await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de/' });
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
BadRequestException,
);
});
it('updateInstance: nicht leere Logo-Adresse ersetzt den Upload, leere entfernt nur die Adresse', async () => {
await service.updateInstance('t1', 'i1', { logoUrl: 'https://logo.example.de/a.png' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
logoUrl: 'https://logo.example.de/a.png',
logoData: null,
logoMime: null,
logoVersion: { increment: 1 },
});
await service.updateInstance('t1', 'i1', { logoUrl: '' });
expect(prisma.nextcloudInstance.update.mock.calls[1][0].data).toEqual({
logoUrl: null,
logoVersion: { increment: 1 },
});
});
it('uploadLogo speichert Bytes und erkannten Typ, entfernt die Adresse und erhoeht die Version', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]);
await service.uploadLogo('t1', 'i1', {
buffer: png,
originalname: 'x.exe',
mimetype: 'text/html',
size: png.length,
});
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
expect(Buffer.from(data.logoData)).toEqual(png);
expect(data).toMatchObject({
logoMime: 'image/png',
logoUrl: null,
logoVersion: { increment: 1 },
});
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT);
});
it('uploadLogo lehnt Nicht-Bilder, fehlende Dateien und fremde Kennungen ab', async () => {
const html = Buffer.from('<html></html>');
await expect(
service.uploadLogo('t1', 'i1', {
buffer: html,
originalname: 'a.png',
mimetype: 'image/png',
size: html.length,
}),
).rejects.toThrow(
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
);
await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException);
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
await expect(
service.uploadLogo('t1', 'fremd', {
buffer: png,
originalname: 'a.png',
mimetype: 'image/png',
size: png.length,
}),
).rejects.toThrow(NotFoundException);
expect(prisma.nextcloudInstance.update).not.toHaveBeenCalled();
});
it('removeLogo loescht Bytes und Typ und erhoeht die Version', async () => {
await service.removeLogo('t1', 'i1');
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
logoData: null,
logoMime: null,
logoVersion: { increment: 1 },
});
});
it('getLogo liefert Bytes und Typ, ohne Logo NotFound', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
logoData: new Uint8Array([1, 2, 3]),
logoMime: 'image/png',
});
const logo = await service.getLogo('t1', 'i1');
expect(logo.mime).toBe('image/png');
expect([...logo.data]).toEqual([1, 2, 3]);
expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
id: 'i1',
tenantId: 't1',
});
prisma.nextcloudInstance.findFirst.mockResolvedValue({ logoData: null, logoMime: null });
await expect(service.getLogo('t1', 'i1')).rejects.toThrow(NotFoundException);
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(service.getLogo('t1', 'x')).rejects.toThrow(NotFoundException);
});
it('deleteInstance loescht die Zeile; fremde Kennung -> NotFound', async () => {
expect(await service.deleteInstance('t1', 'i1')).toBe(true);
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledWith({ where: { id: 'i1' } });
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(service.deleteInstance('t1', 'fremd')).rejects.toThrow(NotFoundException);
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledTimes(1);
});
it('checkAllForTenant prueft alle mit hoechstens vier gleichzeitig und liefert die frische Liste', async () => {
const ids = Array.from({ length: 10 }, (_, i) => `i${i}`);
prisma.nextcloudInstance.findMany.mockImplementation(
async (args: { select: Record<string, boolean> }) =>
args.select.id && Object.keys(args.select).length === 1
? ids.map((id) => ({ id }))
: [makeRow()],
);
prisma.nextcloudInstance.findFirst.mockImplementation(
async ({ where }: { where: { id: string } }) => ({
id: where.id,
baseUrl: 'https://cloud.a.de',
}),
);
let inFlight = 0;
let peak = 0;
vi.mocked(fetchNextcloudStatus).mockImplementation(async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
return OK_RESULT;
});
const result = await service.checkAllForTenant('t1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10);
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(1);
expect(result.instances).toHaveLength(1);
});
it('checkAllForTenant: eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
prisma.nextcloudInstance.findMany.mockImplementation(
async (args: { select: Record<string, boolean> }) =>
Object.keys(args.select).length === 1 ? [{ id: 'a' }, { id: 'b' }] : [],
);
prisma.nextcloudInstance.findFirst.mockImplementation(
async ({ where }: { where: { id: string } }) =>
where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' },
);
await service.checkAllForTenant('t1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
});
it('loadAllInstancesForScheduler waehlt nur Kennung und Mandant ueber forSystem', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]);
const rows = await service.loadAllInstancesForScheduler();
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({
select: { id: true, tenantId: true },
});
expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]);
});
});
});
@@ -1,7 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import type {
CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto';
import { checkLogoUpload } from './nextcloud-logo-rules';
import {
type NextcloudRating,
type NextcloudReference,
@@ -76,10 +81,34 @@ export interface NextcloudListView {
reference: { newestVersion: string | null; fetchedAt: string | null };
}
/** Hoechstzahl gleichzeitiger Pruefungen (Sammelpruefung und stuendlicher Durchlauf). */
export const CHECK_CONCURRENCY = 4;
/**
* Arbeitet `items` mit hoechstens `limit` gleichzeitig ab. `fn` darf werfen —
* der Aufrufer faengt je Eintrag selbst, ein Fehler stoppt die anderen nicht.
*/
export async function runWithConcurrency<T>(
items: T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> {
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const item = items[next++];
await fn(item);
}
});
await Promise.all(workers);
}
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
@Injectable()
export class NextcloudStatusService {
private readonly logger = new Logger(NextcloudStatusService.name);
constructor(
private readonly prisma: PrismaService,
private readonly release: NextcloudReleaseService,
@@ -178,4 +207,172 @@ export class NextcloudStatusService {
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/**
* Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird
* normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine
* nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere
* entfernt nur die Adresse (D-A).
*/
async updateInstance(
tenantId: string,
id: string,
dto: UpdateNextcloudInstanceDto,
): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true, baseUrl: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const data: Record<string, unknown> = {};
if (dto.customerName !== undefined) data.customerName = dto.customerName.trim();
let urlChanged = false;
if (dto.baseUrl !== undefined) {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
if (baseUrl !== existing.baseUrl) {
data.baseUrl = baseUrl;
urlChanged = true;
}
}
if (dto.logoUrl !== undefined) {
if (dto.logoUrl) {
data.logoUrl = dto.logoUrl;
data.logoData = null;
data.logoMime = null;
} else {
data.logoUrl = null;
}
data.logoVersion = { increment: 1 };
}
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data,
select: PUBLIC_SELECT,
});
if (urlChanged) return this.checkInstance(tenantId, id);
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Loescht eine Cloud. Fremde oder unbekannte Kennung: 404. */
async deleteInstance(tenantId: string, id: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
await tenantPrisma.nextcloudInstance.delete({ where: { id } });
return true;
}
/**
* Speichert ein hochgeladenes Logo. Der Typ kommt aus den Magic Bytes
* (`checkLogoUpload`), nie aus dem Mimetype des Browsers; eine vorhandene
* Logo-Adresse wird entfernt (Upload und Adresse schliessen sich aus).
*/
async uploadLogo(
tenantId: string,
id: string,
file: UploadedFileLike | undefined,
): Promise<NextcloudInstanceView> {
const mime = file ? checkLogoUpload(file.buffer) : null;
if (!file || !mime) {
throw new BadRequestException(
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
);
}
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: {
logoData: new Uint8Array(file.buffer),
logoMime: mime,
logoUrl: null,
logoVersion: { increment: 1 },
},
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Liefert die Bytes des hochgeladenen Logos — die einzige Abfrage, die `logoData` auswaehlt. */
async getLogo(tenantId: string, id: string): Promise<{ data: Buffer; mime: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { logoData: true, logoMime: true },
});
if (!row?.logoData || !row.logoMime) throw new NotFoundException('Kein Logo vorhanden');
return { data: Buffer.from(row.logoData), mime: row.logoMime };
}
/** Entfernt ein hochgeladenes Logo (die Kachel faellt auf Initialen zurueck). */
async removeLogo(tenantId: string, id: string): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } },
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Kennungen aller Clouds des Mandanten. */
async listInstanceIdsForTenant(tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = await tenantPrisma.nextcloudInstance.findMany({
where: { tenantId },
select: { id: true },
});
return rows.map((r: { id: string }) => r.id);
}
/**
* "Jetzt pruefen" fuer die ganze Liste: alle Clouds des Mandanten mit
* hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte
* Cloud stoppt die anderen nicht.
*/
async checkAllForTenant(tenantId: string): Promise<NextcloudListView> {
const ids = await this.listInstanceIdsForTenant(tenantId);
await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => {
try {
await this.checkInstance(tenantId, id);
} catch (err) {
this.logger.warn(
`Nextcloud-Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`,
);
}
});
return this.listForTenant(tenantId);
}
/**
* Startpfad des stuendlichen Planers — der EINZIGE Systemkontext-Aufruf
* dieses Moduls (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`;
* Leserecht ueber `system_read_policy ... FOR SELECT` der Migration
* 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder
* Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen
* Mandanten gebunden (`checkInstance`).
*/
async loadAllInstancesForScheduler(): Promise<{ id: string; tenantId: string }[]> {
const systemPrisma = forSystem(this.prisma);
return systemPrisma.nextcloudInstance.findMany({
select: { id: true, tenantId: true },
});
}
}
@@ -189,10 +189,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist
* `system_read_policy ... FOR SELECT` auf "Reminder" (Migration
* 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe.
*
* quick-261002-k67 (Aufgabe 2): eine siebte Datei kommt hinzu —
* `nextcloud-status/nextcloud-status.service.ts`, EIN Aufruf:
* `loadAllInstancesForScheduler()` liest fuer den stuendlichen Planer nur
* Kennung und Mandant ALLER Clouds (`select: { id, tenantId }`, nie Logo-Bytes).
* Geprueft und geschrieben wird danach je Cloud gebunden ueber
* `forTenant(prisma, tenantId)`. Die passende Regel ist
* `system_read_policy ... FOR SELECT` auf "NextcloudInstance" (Migration
* 20261002150000). Summe neu: 7 Dateien, 8 Aufrufe.
*/
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dkv/dkv.service.ts', 1],
['apps/api/src/ldap/ldap-config.service.ts', 2],
['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1],
['apps/api/src/proxmox/proxmox.service.ts', 1],
['apps/api/src/reminders/reminder-mail.scheduler.ts', 1],
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],