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:
@@ -7,6 +7,7 @@ import { DkvController } from '../dkv/dkv.controller';
|
|||||||
import { ModuleGrantsController } from '../groups/module-grants.controller';
|
import { ModuleGrantsController } from '../groups/module-grants.controller';
|
||||||
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
|
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
|
||||||
import { KantineDatevController } from '../kantine-datev/kantine-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 { ProxmoxController } from '../proxmox/proxmox.controller';
|
||||||
import { TendersController } from '../tenders/tenders.controller';
|
import { TendersController } from '../tenders/tenders.controller';
|
||||||
import { ModuleRegistryController } from './module-registry.controller';
|
import { ModuleRegistryController } from './module-registry.controller';
|
||||||
@@ -70,6 +71,19 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
|||||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
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', () => {
|
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
|
||||||
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
|
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
|
||||||
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-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);
|
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('list bleibt auf Benutzen-Ebene', () => {
|
it.each(['list', 'logo'])('%s bleibt auf Benutzen-Ebene', (name) => {
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.list as object)).toBeUndefined();
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, proto.list 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;
|
const fn = proto[name] as object;
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true);
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true);
|
||||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status');
|
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status');
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
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 {
|
||||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
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 { 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';
|
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,14 +28,15 @@ import { NextcloudStatusService } from './nextcloud-status.service';
|
|||||||
* Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich
|
* Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich
|
||||||
* aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query.
|
* aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query.
|
||||||
*
|
*
|
||||||
* Rechte (L-09): Lesen (`GET instances`, Logo-Abruf) steht jedem Benutzer mit
|
* Rechte (L-09): Lesen (`GET instances`, `GET instances/:id/logo`) steht jedem
|
||||||
* Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
|
* Benutzer mit Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
|
||||||
* `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der
|
* `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der
|
||||||
* Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein
|
* Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein
|
||||||
* Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren.
|
* Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren.
|
||||||
*
|
*
|
||||||
* Routenreihenfolge: statische Pfade (`instances/check`) stehen VOR allen
|
* 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')
|
@Controller('modules/nextcloud-status')
|
||||||
@UseModule('nextcloud-status')
|
@UseModule('nextcloud-status')
|
||||||
@@ -42,9 +62,73 @@ export class NextcloudStatusController {
|
|||||||
return this.service.createInstance(this.requireTenantId(req), dto);
|
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')
|
@Post('instances/:id/check')
|
||||||
@ModuleManage('nextcloud-status')
|
@ModuleManage('nextcloud-status')
|
||||||
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
return this.service.checkInstance(this.requireTenantId(req), id);
|
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 { NextcloudStatusController } from './nextcloud-status.controller';
|
||||||
import { seedNextcloudStatusModule } from './nextcloud-status.seed';
|
import { seedNextcloudStatusModule } from './nextcloud-status.seed';
|
||||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||||
|
import { NextcloudStatusSchedulerService } from './nextcloud-status-scheduler.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NestJS module for the Nextcloud-Status feature (quick-261002-k67).
|
* NestJS module for the Nextcloud-Status feature (quick-261002-k67).
|
||||||
@@ -13,7 +14,7 @@ import { NextcloudStatusService } from './nextcloud-status.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [ModuleRegistryModule],
|
imports: [ModuleRegistryModule],
|
||||||
controllers: [NextcloudStatusController],
|
controllers: [NextcloudStatusController],
|
||||||
providers: [NextcloudStatusService, NextcloudReleaseService],
|
providers: [NextcloudStatusService, NextcloudReleaseService, NextcloudStatusSchedulerService],
|
||||||
})
|
})
|
||||||
export class NextcloudStatusModule implements OnModuleInit {
|
export class NextcloudStatusModule implements OnModuleInit {
|
||||||
private readonly logger = new Logger(NextcloudStatusModule.name);
|
private readonly logger = new Logger(NextcloudStatusModule.name);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
|
|||||||
return { ...actual, fetchNextcloudStatus: vi.fn() };
|
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 type { NextcloudReference } from './nextcloud-rating';
|
||||||
import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service';
|
import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service';
|
||||||
import { fetchNextcloudStatus } from './nextcloud-status-fetch';
|
import { fetchNextcloudStatus } from './nextcloud-status-fetch';
|
||||||
@@ -177,4 +177,199 @@ describe('NextcloudStatusService', () => {
|
|||||||
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
|
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
|
||||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
import type {
|
||||||
|
CreateNextcloudInstanceDto,
|
||||||
|
UpdateNextcloudInstanceDto,
|
||||||
|
} from './dto/nextcloud-instance.dto';
|
||||||
|
import { checkLogoUpload } from './nextcloud-logo-rules';
|
||||||
import {
|
import {
|
||||||
type NextcloudRating,
|
type NextcloudRating,
|
||||||
type NextcloudReference,
|
type NextcloudReference,
|
||||||
@@ -76,10 +81,34 @@ export interface NextcloudListView {
|
|||||||
reference: { newestVersion: string | null; fetchedAt: string | null };
|
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.';
|
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NextcloudStatusService {
|
export class NextcloudStatusService {
|
||||||
|
private readonly logger = new Logger(NextcloudStatusService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly release: NextcloudReleaseService,
|
private readonly release: NextcloudReleaseService,
|
||||||
@@ -178,4 +207,172 @@ export class NextcloudStatusService {
|
|||||||
});
|
});
|
||||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
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
|
* gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist
|
||||||
* `system_read_policy ... FOR SELECT` auf "Reminder" (Migration
|
* `system_read_policy ... FOR SELECT` auf "Reminder" (Migration
|
||||||
* 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe.
|
* 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>([
|
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
['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/proxmox/proxmox.service.ts', 1],
|
||||||
['apps/api/src/reminders/reminder-mail.scheduler.ts', 1],
|
['apps/api/src/reminders/reminder-mail.scheduler.ts', 1],
|
||||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { NextcloudInstance } from '@/lib/nextcloud-status-api';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
import { CloudForm } from './CloudForm';
|
||||||
|
|
||||||
|
function render(ui: ReactElement) {
|
||||||
|
return rtlRender(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
createInstance: vi.fn(),
|
||||||
|
updateInstance: vi.fn(),
|
||||||
|
deleteInstance: vi.fn(),
|
||||||
|
uploadLogo: vi.fn(),
|
||||||
|
removeLogo: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createInstance: (...a: unknown[]) => api.createInstance(...a),
|
||||||
|
updateInstance: (...a: unknown[]) => api.updateInstance(...a),
|
||||||
|
deleteInstance: (...a: unknown[]) => api.deleteInstance(...a),
|
||||||
|
uploadLogo: (...a: unknown[]) => api.uploadLogo(...a),
|
||||||
|
removeLogo: (...a: unknown[]) => api.removeLogo(...a),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const EXISTING: NextcloudInstance = {
|
||||||
|
id: 'i1',
|
||||||
|
customerName: 'Kunde A',
|
||||||
|
baseUrl: 'https://a.example.de',
|
||||||
|
logoUrl: null,
|
||||||
|
hasUploadedLogo: false,
|
||||||
|
logoVersion: 0,
|
||||||
|
status: {
|
||||||
|
checkedAt: null,
|
||||||
|
reachable: true,
|
||||||
|
maintenance: false,
|
||||||
|
needsDbUpgrade: false,
|
||||||
|
versionString: '35.0.1',
|
||||||
|
edition: null,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
},
|
||||||
|
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(instance: NextcloudInstance | null = null) {
|
||||||
|
const props = { onClose: vi.fn(), onSaved: vi.fn(), onDeleted: vi.fn() };
|
||||||
|
render(<CloudForm instance={instance} {...props} />);
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CloudForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(api)) fn.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
it('verlangt Kundenname und Adresse', async () => {
|
||||||
|
setup();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Bitte füllen Sie Kundenname und Adresse aus.',
|
||||||
|
);
|
||||||
|
expect(api.createInstance).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt eine Cloud an', async () => {
|
||||||
|
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
|
||||||
|
const props = setup();
|
||||||
|
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: ' Neu GmbH ' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||||
|
target: { value: 'https://cloud.neu.de' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
await waitFor(() => expect(props.onSaved).toHaveBeenCalled());
|
||||||
|
expect(api.createInstance).toHaveBeenCalledWith({
|
||||||
|
customerName: 'Neu GmbH',
|
||||||
|
baseUrl: 'https://cloud.neu.de',
|
||||||
|
});
|
||||||
|
expect(api.uploadLogo).not.toHaveBeenCalled();
|
||||||
|
expect(props.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lädt nach dem Anlegen das gewählte Logo hoch', async () => {
|
||||||
|
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
|
||||||
|
api.uploadLogo.mockResolvedValue({ ...EXISTING, id: 'neu', hasUploadedLogo: true });
|
||||||
|
const props = setup();
|
||||||
|
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||||
|
target: { value: 'https://cloud.neu.de' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
|
||||||
|
const file = new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' });
|
||||||
|
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
|
||||||
|
target: { files: [file] },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
await waitFor(() => expect(api.uploadLogo).toHaveBeenCalledWith('neu', file));
|
||||||
|
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ hasUploadedLogo: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendet eine https-Bildadresse und weist darauf hin', async () => {
|
||||||
|
api.createInstance.mockResolvedValue(EXISTING);
|
||||||
|
setup();
|
||||||
|
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||||
|
target: { value: 'https://cloud.neu.de' },
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="radio"]' }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/muss mit https:\/\/ beginnen/)).toBeInTheDocument();
|
||||||
|
fireEvent.change(
|
||||||
|
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="url"]' }),
|
||||||
|
{
|
||||||
|
target: { value: 'https://logo.example.de/a.png' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.createInstance).toHaveBeenCalledWith({
|
||||||
|
customerName: 'Neu',
|
||||||
|
baseUrl: 'https://cloud.neu.de',
|
||||||
|
logoUrl: 'https://logo.example.de/a.png',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine zu große Datei sofort ab', async () => {
|
||||||
|
setup();
|
||||||
|
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
|
||||||
|
const big = new File([new Uint8Array(1024 * 1024 + 1)], 'groß.png', { type: 'image/png' });
|
||||||
|
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
|
||||||
|
target: { files: [big] },
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert eine vorhandene Cloud', async () => {
|
||||||
|
api.updateInstance.mockResolvedValue(EXISTING);
|
||||||
|
const props = setup(EXISTING);
|
||||||
|
expect(screen.getByLabelText('Kundenname')).toHaveValue('Kunde A');
|
||||||
|
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Kunde B' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.updateInstance).toHaveBeenCalledWith('i1', {
|
||||||
|
customerName: 'Kunde B',
|
||||||
|
baseUrl: 'https://a.example.de',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(props.onSaved).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt ein hochgeladenes Logo über „Kein Logo“', async () => {
|
||||||
|
const withLogo = { ...EXISTING, hasUploadedLogo: true, logoVersion: 2 };
|
||||||
|
api.updateInstance.mockResolvedValue(withLogo);
|
||||||
|
api.removeLogo.mockResolvedValue(EXISTING);
|
||||||
|
setup(withLogo);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Logo entfernen' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
await waitFor(() => expect(api.removeLogo).toHaveBeenCalledWith('i1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Entfernen fragt erst nach und ruft dann deleteInstance', async () => {
|
||||||
|
api.deleteInstance.mockResolvedValue(undefined);
|
||||||
|
const props = setup(EXISTING);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cloud entfernen' }));
|
||||||
|
expect(
|
||||||
|
screen.getByText('Möchten Sie die Cloud „Kunde A“ wirklich entfernen?'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(api.deleteInstance).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Endgültig entfernen' }));
|
||||||
|
await waitFor(() => expect(api.deleteInstance).toHaveBeenCalledWith('i1'));
|
||||||
|
expect(props.onDeleted).toHaveBeenCalledWith('i1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt die Fehlermeldung der API im Formular', async () => {
|
||||||
|
api.createInstance.mockRejectedValue(
|
||||||
|
new Error('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'),
|
||||||
|
);
|
||||||
|
const props = setup();
|
||||||
|
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { target: { value: 'ftp://x' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Bitte geben Sie eine gültige Adresse',
|
||||||
|
);
|
||||||
|
expect(props.onSaved).not.toHaveBeenCalled();
|
||||||
|
expect(props.onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
createInstance,
|
||||||
|
deleteInstance,
|
||||||
|
logoSrc,
|
||||||
|
type NextcloudInstance,
|
||||||
|
removeLogo,
|
||||||
|
updateInstance,
|
||||||
|
uploadLogo,
|
||||||
|
} from '@/lib/nextcloud-status-api';
|
||||||
|
|
||||||
|
type LogoMode = 'none' | 'upload' | 'url';
|
||||||
|
|
||||||
|
/** Obergrenze fuer ein Logo (wie die API: 1 MiB). */
|
||||||
|
const LOGO_MAX_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
interface CloudFormProps {
|
||||||
|
/** `null` = neue Cloud anlegen, sonst bearbeiten. */
|
||||||
|
instance: NextcloudInstance | null;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Liefert die gespeicherte Cloud (mit frischer Pruefung und Bewertung). */
|
||||||
|
onSaved: (instance: NextcloudInstance) => void;
|
||||||
|
onDeleted: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialMode(instance: NextcloudInstance | null): LogoMode {
|
||||||
|
if (instance?.hasUploadedLogo) return 'upload';
|
||||||
|
if (instance?.logoUrl) return 'url';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular „Cloud hinzufuegen / bearbeiten“ (quick-261002-k67, L-02, L-09) —
|
||||||
|
* Aufbau wie `CustomModuleFormModal`. Nur fuer Verwalter erreichbar (die Seite
|
||||||
|
* zeigt die Zugaenge nicht fuer Benutzer ohne Verwalten); bindend ist allein
|
||||||
|
* der ModuleGuard der API. Das Logo ist entweder ein Upload (die API prueft den
|
||||||
|
* Typ an den Bytes) oder eine https-Bildadresse, die der Browser selbst laedt.
|
||||||
|
* Anlegen wartet auf die erste Pruefung („Wird geprueft …“).
|
||||||
|
*/
|
||||||
|
export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormProps) {
|
||||||
|
const t = useTranslations('nextcloudStatus.form');
|
||||||
|
const [customerName, setCustomerName] = useState(instance?.customerName ?? '');
|
||||||
|
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? '');
|
||||||
|
const [mode, setMode] = useState<LogoMode>(initialMode(instance));
|
||||||
|
const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? '');
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const currentLogo =
|
||||||
|
instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null;
|
||||||
|
|
||||||
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const picked = e.target.files?.[0] ?? null;
|
||||||
|
if (picked && picked.size > LOGO_MAX_BYTES) {
|
||||||
|
setError(t('logoFileTooLarge'));
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setFile(picked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const name = customerName.trim();
|
||||||
|
const url = baseUrl.trim();
|
||||||
|
if (!name || !url) {
|
||||||
|
setError(t('required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let saved: NextcloudInstance;
|
||||||
|
if (instance) {
|
||||||
|
const payload: { customerName: string; baseUrl: string; logoUrl?: string } = {
|
||||||
|
customerName: name,
|
||||||
|
baseUrl: url,
|
||||||
|
};
|
||||||
|
if (mode === 'url') payload.logoUrl = logoUrl.trim();
|
||||||
|
else if (mode === 'none' && instance.logoUrl) payload.logoUrl = '';
|
||||||
|
saved = await updateInstance(instance.id, payload);
|
||||||
|
if (mode === 'none' && instance.hasUploadedLogo) saved = await removeLogo(instance.id);
|
||||||
|
if (mode === 'upload' && file) saved = await uploadLogo(instance.id, file);
|
||||||
|
} else {
|
||||||
|
saved = await createInstance({
|
||||||
|
customerName: name,
|
||||||
|
baseUrl: url,
|
||||||
|
...(mode === 'url' && logoUrl.trim() ? { logoUrl: logoUrl.trim() } : {}),
|
||||||
|
});
|
||||||
|
if (mode === 'upload' && file) saved = await uploadLogo(saved.id, file);
|
||||||
|
}
|
||||||
|
onSaved(saved);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error && err.message ? err.message : t('saveError'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!instance) return;
|
||||||
|
setError(null);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await deleteInstance(instance.id);
|
||||||
|
onDeleted(instance.id);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error && err.message ? err.message : t('deleteError'));
|
||||||
|
setConfirmDelete(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="cloud-form-title"
|
||||||
|
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
|
||||||
|
>
|
||||||
|
<h2 id="cloud-form-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||||
|
{instance ? t('editTitle') : t('addTitle')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{confirmDelete && instance ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
{t('deleteConfirm', { name: instance.customerName })}
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{t('deleteConfirmYes')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="cloud-customer-name" className="text-sm font-medium text-foreground">
|
||||||
|
{t('customerName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cloud-customer-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)}
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="cloud-base-url" className="text-sm font-medium text-foreground">
|
||||||
|
{t('baseUrl')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cloud-base-url"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={2048}
|
||||||
|
placeholder={t('baseUrlPlaceholder')}
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('baseUrlHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="space-y-2">
|
||||||
|
<legend className="text-sm font-medium text-foreground">{t('logo')}</legend>
|
||||||
|
{currentLogo && mode !== 'none' && (
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
{/* biome-ignore lint/performance/noImgElement: Vorschau einer externen oder hochgeladenen Adresse */}
|
||||||
|
<img
|
||||||
|
src={currentLogo}
|
||||||
|
alt={t('logoCurrent')}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="h-10 w-10 rounded-md bg-well object-contain"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setMode('none')} className="btn btn-subtle">
|
||||||
|
{t('logoRemove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
|
{(['none', 'upload', 'url'] as const).map((value) => (
|
||||||
|
<label key={value} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cloud-logo-mode"
|
||||||
|
value={value}
|
||||||
|
checked={mode === value}
|
||||||
|
onChange={() => setMode(value)}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
|
value === 'none' ? 'logoNone' : value === 'upload' ? 'logoUpload' : 'logoUrl',
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{mode === 'upload' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
aria-label={t('logoUpload')}
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
onChange={handleFile}
|
||||||
|
className="block w-full text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('logoFileHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'url' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
aria-label={t('logoUrl')}
|
||||||
|
maxLength={2048}
|
||||||
|
placeholder={t('logoUrlPlaceholder')}
|
||||||
|
value={logoUrl}
|
||||||
|
onChange={(e) => setLogoUrl(e.target.value)}
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('logoUrlHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||||
|
<div>
|
||||||
|
{instance && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="btn btn-subtle"
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={saving} className="btn btn-primary">
|
||||||
|
{saving ? t('saving') : t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ interface CloudTileProps {
|
|||||||
instance: NextcloudInstance;
|
instance: NextcloudInstance;
|
||||||
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
|
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
|
||||||
now?: number;
|
now?: number;
|
||||||
|
/** Verwalten-Ebene: zeigt die Knoepfe Pruefen und Bearbeiten (nur Anzeige, bindend ist die API). */
|
||||||
|
canManage?: boolean;
|
||||||
|
/** Eine Pruefung dieser Kachel laeuft (Drehsymbol, Knopf gesperrt). */
|
||||||
|
checking?: boolean;
|
||||||
|
onCheck?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +32,14 @@ interface CloudTileProps {
|
|||||||
* Zeitpunkt der letzten Pruefung. Die Logo-Adresse kommt vom Verwalter und
|
* Zeitpunkt der letzten Pruefung. Die Logo-Adresse kommt vom Verwalter und
|
||||||
* wird vom Browser geladen — ohne Referer (`referrerPolicy`, T-k67-05).
|
* wird vom Browser geladen — ohne Referer (`referrerPolicy`, T-k67-05).
|
||||||
*/
|
*/
|
||||||
export function CloudTile({ instance, now = Date.now() }: CloudTileProps) {
|
export function CloudTile({
|
||||||
|
instance,
|
||||||
|
now = Date.now(),
|
||||||
|
canManage = false,
|
||||||
|
checking = false,
|
||||||
|
onCheck,
|
||||||
|
onEdit,
|
||||||
|
}: CloudTileProps) {
|
||||||
const t = useTranslations('nextcloudStatus');
|
const t = useTranslations('nextcloudStatus');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const [logoFailed, setLogoFailed] = useState(false);
|
const [logoFailed, setLogoFailed] = useState(false);
|
||||||
@@ -79,6 +92,58 @@ export function CloudTile({ instance, now = Date.now() }: CloudTileProps) {
|
|||||||
{instance.baseUrl}
|
{instance.baseUrl}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCheck}
|
||||||
|
disabled={checking}
|
||||||
|
aria-label={t('cardActions.refresh')}
|
||||||
|
title={t('cardActions.refresh')}
|
||||||
|
className="btn btn-subtle"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={checking ? 'animate-spin motion-reduce:animate-none' : ''}
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||||
|
<polyline points="21 3 21 8 16 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
aria-label={t('cardActions.edit')}
|
||||||
|
title={t('cardActions.edit')}
|
||||||
|
className="btn btn-subtle"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
@@ -15,12 +22,29 @@ function render(ui: ReactElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mockListInstances = vi.fn();
|
const mockListInstances = vi.fn();
|
||||||
|
const mockCheckAll = vi.fn();
|
||||||
|
const mockCheckOne = vi.fn();
|
||||||
|
|
||||||
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
|
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
|
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
|
||||||
return { ...actual, listInstances: (...args: unknown[]) => mockListInstances(...args) };
|
return {
|
||||||
|
...actual,
|
||||||
|
listInstances: (...args: unknown[]) => mockListInstances(...args),
|
||||||
|
checkAll: (...args: unknown[]) => mockCheckAll(...args),
|
||||||
|
checkOne: (...args: unknown[]) => mockCheckOne(...args),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mockCanManage: boolean | null = false;
|
||||||
|
vi.mock('@/lib/use-module-capability', () => ({
|
||||||
|
useCanManageModule: () => mockCanManage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/stores/auth-store', () => ({
|
||||||
|
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
|
||||||
|
selector({ user: { id: 'u1' } }),
|
||||||
|
}));
|
||||||
|
|
||||||
function makeInstance(
|
function makeInstance(
|
||||||
over: Partial<NextcloudInstance> & { id: string; customerName: string },
|
over: Partial<NextcloudInstance> & { id: string; customerName: string },
|
||||||
): NextcloudInstance {
|
): NextcloudInstance {
|
||||||
@@ -101,6 +125,10 @@ const LIST: NextcloudList = {
|
|||||||
describe('NextcloudStatusPage', () => {
|
describe('NextcloudStatusPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockListInstances.mockReset();
|
mockListInstances.mockReset();
|
||||||
|
mockCheckAll.mockReset();
|
||||||
|
mockCheckOne.mockReset();
|
||||||
|
mockCanManage = false;
|
||||||
|
window.localStorage.clear();
|
||||||
});
|
});
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
@@ -167,4 +195,94 @@ describe('NextcloudStatusPage', () => {
|
|||||||
const tile = await screen.findByTestId('cloud-tile');
|
const tile = await screen.findByTestId('cloud-tile');
|
||||||
expect(within(tile).getByText('KG')).toBeInTheDocument();
|
expect(within(tile).getByText('KG')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
describe('Rechte und Sortierung', () => {
|
||||||
|
const tileNames = () =>
|
||||||
|
screen.getAllByTestId('cloud-tile').map((t) => t.getAttribute('aria-label')?.split(':')[0]);
|
||||||
|
|
||||||
|
it('Benutzen-Ebene: Kacheln und Sortierung ja, Verwalten-Knöpfe nein', async () => {
|
||||||
|
mockCanManage = false;
|
||||||
|
mockListInstances.mockResolvedValue(LIST);
|
||||||
|
render(<NextcloudStatusPage />);
|
||||||
|
await screen.findAllByTestId('cloud-tile');
|
||||||
|
expect(screen.getByLabelText('Sortieren nach')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Cloud hinzufügen' })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Jetzt prüfen' })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Cloud bearbeiten' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Verwalter sehen alle Knöpfe', async () => {
|
||||||
|
mockCanManage = true;
|
||||||
|
mockListInstances.mockResolvedValue(LIST);
|
||||||
|
render(<NextcloudStatusPage />);
|
||||||
|
await screen.findAllByTestId('cloud-tile');
|
||||||
|
expect(screen.getByRole('button', { name: 'Cloud hinzufügen' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Jetzt prüfen' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toHaveLength(4);
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Cloud bearbeiten' })).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Status-Sortierung stellt Rot nach vorn und merkt die Auswahl', async () => {
|
||||||
|
mockListInstances.mockResolvedValue(LIST);
|
||||||
|
render(<NextcloudStatusPage />);
|
||||||
|
await screen.findAllByTestId('cloud-tile');
|
||||||
|
expect(tileNames()).toEqual(['Kunde Gelb', 'Kunde Grau', 'Kunde Grün', 'Kunde Rot']);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Sortieren nach'), { target: { value: 'status' } });
|
||||||
|
expect(tileNames()).toEqual(['Kunde Rot', 'Kunde Gelb', 'Kunde Grau', 'Kunde Grün']);
|
||||||
|
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wendet eine gemerkte Sortierung beim Laden an', async () => {
|
||||||
|
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'status');
|
||||||
|
mockListInstances.mockResolvedValue(LIST);
|
||||||
|
render(<NextcloudStatusPage />);
|
||||||
|
await screen.findAllByTestId('cloud-tile');
|
||||||
|
await waitFor(() => expect(tileNames()[0]).toBe('Kunde Rot'));
|
||||||
|
expect((screen.getByLabelText('Sortieren nach') as HTMLSelectElement).value).toBe('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Jetzt prüfen“ ruft checkAll und ersetzt die Liste', async () => {
|
||||||
|
mockCanManage = true;
|
||||||
|
mockListInstances.mockResolvedValue(LIST);
|
||||||
|
mockCheckAll.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
|
||||||
|
render(<NextcloudStatusPage />);
|
||||||
|
await screen.findAllByTestId('cloud-tile');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Jetzt prüfen' }));
|
||||||
|
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(1));
|
||||||
|
expect(mockCheckAll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kachel-Prüfung ersetzt nur diese Kachel', async () => {
|
||||||
|
mockCanManage = true;
|
||||||
|
mockListInstances.mockResolvedValue(LIST);
|
||||||
|
mockCheckOne.mockResolvedValue({
|
||||||
|
...LIST.instances[2],
|
||||||
|
customerName: 'Kunde Rot',
|
||||||
|
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
|
||||||
|
});
|
||||||
|
render(<NextcloudStatusPage />);
|
||||||
|
const tiles = await screen.findAllByTestId('cloud-tile');
|
||||||
|
const red = tiles.find((t) =>
|
||||||
|
t.getAttribute('aria-label')?.startsWith('Kunde Rot'),
|
||||||
|
) as HTMLElement;
|
||||||
|
fireEvent.click(within(red).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }));
|
||||||
|
await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c'));
|
||||||
|
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(4));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getAllByTestId('cloud-tile').find((t) => t.textContent?.includes('Kunde Rot')),
|
||||||
|
).toHaveAttribute('data-level', 'green'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Cloud hinzufügen“ öffnet das Formular', async () => {
|
||||||
|
mockCanManage = true;
|
||||||
|
mockListInstances.mockResolvedValue(LIST);
|
||||||
|
render(<NextcloudStatusPage />);
|
||||||
|
await screen.findAllByTestId('cloud-tile');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cloud hinzufügen' }));
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import {
|
||||||
|
readSortPreference,
|
||||||
|
SORT_KEYS,
|
||||||
|
type SortKey,
|
||||||
|
sortClouds,
|
||||||
|
writeSortPreference,
|
||||||
|
} from '@/components/nextcloud-status/sort-clouds';
|
||||||
import { WELL } from '@/components/proxmox/status-styles';
|
import { WELL } from '@/components/proxmox/status-styles';
|
||||||
import { listInstances, type NextcloudList } from '@/lib/nextcloud-status-api';
|
import {
|
||||||
|
checkAll,
|
||||||
|
checkOne,
|
||||||
|
listInstances,
|
||||||
|
type NextcloudInstance,
|
||||||
|
type NextcloudList,
|
||||||
|
} from '@/lib/nextcloud-status-api';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||||
|
import { CloudForm } from './components/CloudForm';
|
||||||
import { CloudTile } from './components/CloudTile';
|
import { CloudTile } from './components/CloudTile';
|
||||||
|
|
||||||
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
||||||
@@ -25,16 +41,50 @@ function SkeletonTile() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RefreshIcon({ spinning }: { spinning: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={spinning ? 'animate-spin motion-reduce:animate-none' : ''}
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||||
|
<polyline points="21 3 21 8 16 8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modulseite Nextcloud-Status (quick-261002-k67): eine Kachel je Cloud mit
|
* Modulseite Nextcloud-Status (quick-261002-k67): eine Kachel je Cloud mit
|
||||||
* Ampel, installierter Version und Zeitpunkt der letzten Pruefung. Die
|
* Ampel, installierter Version und Zeitpunkt der letzten Pruefung. Die
|
||||||
* Bewertung berechnet die API beim Lesen (D-B) — diese Seite zeigt nur an.
|
* Bewertung berechnet die API beim Lesen (D-B) — diese Seite zeigt nur an.
|
||||||
|
*
|
||||||
|
* Sortierung (L-07) steht jedem Benutzer offen und wird je Benutzer im
|
||||||
|
* Browser gemerkt. Hinzufuegen, Bearbeiten, Pruefen („Jetzt pruefen“, je
|
||||||
|
* Kachel) nur mit Verwalten (`useCanManageModule`, L-09); die Knoepfe sind
|
||||||
|
* reine Anzeige — bindend ist `@ModuleManage` an der API.
|
||||||
*/
|
*/
|
||||||
export default function NextcloudStatusPage() {
|
export default function NextcloudStatusPage() {
|
||||||
const t = useTranslations('nextcloudStatus');
|
const t = useTranslations('nextcloudStatus');
|
||||||
|
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||||
|
const canManage = useCanManageModule('nextcloud-status') === true;
|
||||||
|
|
||||||
const [data, setData] = useState<NextcloudList | null>(null);
|
const [data, setData] = useState<NextcloudList | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||||
|
const [checkingAll, setCheckingAll] = useState(false);
|
||||||
|
const [checkingIds, setCheckingIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
// `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten.
|
||||||
|
const [editing, setEditing] = useState<NextcloudInstance | null | undefined>(undefined);
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
listInstances()
|
listInstances()
|
||||||
@@ -54,11 +104,82 @@ export default function NextcloudStatusPage() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Gemerkte Sortierung dieses Benutzers anwenden, sobald seine Kennung feststeht.
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) setSortKey(readSortPreference(userId));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const handleSort = (key: SortKey) => {
|
||||||
|
setSortKey(key);
|
||||||
|
if (userId) writeSortPreference(userId, key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckAll = async () => {
|
||||||
|
setCheckingAll(true);
|
||||||
|
try {
|
||||||
|
const list = await checkAll();
|
||||||
|
setData(list);
|
||||||
|
setNow(Date.now());
|
||||||
|
} catch {
|
||||||
|
setError(t('loadError'));
|
||||||
|
} finally {
|
||||||
|
setCheckingAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckOne = async (id: string) => {
|
||||||
|
setCheckingIds((prev) => new Set(prev).add(id));
|
||||||
|
try {
|
||||||
|
const updated = await checkOne(id);
|
||||||
|
setData((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, instances: prev.instances.map((i) => (i.id === id ? updated : i)) }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
setNow(Date.now());
|
||||||
|
} catch {
|
||||||
|
/* die Kachel behaelt ihren bisherigen Stand */
|
||||||
|
} finally {
|
||||||
|
setCheckingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die
|
||||||
|
// neueste Version kommen so wieder aus einer Quelle.
|
||||||
|
const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]);
|
||||||
const newest = data?.reference.newestVersion ?? null;
|
const newest = data?.reference.newestVersion ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
|
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
|
||||||
<PageHeader moduleSlug="nextcloud-status" title={t('title')} description={t('description')} />
|
<PageHeader
|
||||||
|
moduleSlug="nextcloud-status"
|
||||||
|
title={t('title')}
|
||||||
|
description={t('description')}
|
||||||
|
actions={
|
||||||
|
canManage ? (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setEditing(null)} className="btn btn-subtle">
|
||||||
|
{t('actions.add')}
|
||||||
|
</button>
|
||||||
|
{data !== null && data.instances.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCheckAll}
|
||||||
|
disabled={checkingAll}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<RefreshIcon spinning={checkingAll} />
|
||||||
|
{checkingAll ? t('actions.checking') : t('actions.checkAll')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
|
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
|
||||||
@@ -79,26 +200,62 @@ export default function NextcloudStatusPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && data !== null && (
|
{!error && data !== null && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-sm text-muted-foreground" data-testid="newest-version">
|
<p className="text-sm text-muted-foreground" data-testid="newest-version">
|
||||||
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
|
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
|
||||||
</p>
|
</p>
|
||||||
|
{data.instances.length > 0 && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{t('sort.label')}
|
||||||
|
<select
|
||||||
|
value={sortKey}
|
||||||
|
onChange={(e) => handleSort(e.target.value as SortKey)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
{SORT_KEYS.map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{t(`sort.${key}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && data !== null && data.instances.length === 0 && (
|
{!error && data !== null && data.instances.length === 0 && (
|
||||||
<div className={`${WELL} px-6 py-10 text-center`}>
|
<div className={`${WELL} px-6 py-10 text-center`}>
|
||||||
<p className="text-sm text-muted-foreground">{t('empty')}</p>
|
<p className="text-sm text-muted-foreground">{t('empty')}</p>
|
||||||
|
{canManage && <p className="mt-1 text-sm text-muted-foreground">{t('emptyManage')}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && data !== null && data.instances.length > 0 && (
|
{!error && data !== null && sorted.length > 0 && (
|
||||||
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{data.instances.map((instance) => (
|
{sorted.map((instance) => (
|
||||||
<li key={instance.id} className="min-w-0">
|
<li key={instance.id} className="min-w-0">
|
||||||
<CloudTile instance={instance} now={now} />
|
<CloudTile
|
||||||
|
key={`${instance.id}:${instance.logoVersion}`}
|
||||||
|
instance={instance}
|
||||||
|
now={now}
|
||||||
|
canManage={canManage}
|
||||||
|
checking={checkingAll || checkingIds.has(instance.id)}
|
||||||
|
onCheck={() => handleCheckOne(instance.id)}
|
||||||
|
onEdit={() => setEditing(instance)}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editing !== undefined && (
|
||||||
|
<CloudForm
|
||||||
|
instance={editing}
|
||||||
|
onClose={() => setEditing(undefined)}
|
||||||
|
onSaved={() => reload()}
|
||||||
|
onDeleted={() => reload()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
|
||||||
|
import { readSortPreference, sortClouds, writeSortPreference } from './sort-clouds';
|
||||||
|
|
||||||
|
function make(
|
||||||
|
name: string,
|
||||||
|
level: RatingLevel = 'green',
|
||||||
|
version: string | null = '35.0.1',
|
||||||
|
eolDate: string | null = null,
|
||||||
|
): NextcloudInstance {
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
customerName: name,
|
||||||
|
baseUrl: `https://${name}.de`,
|
||||||
|
logoUrl: null,
|
||||||
|
hasUploadedLogo: false,
|
||||||
|
logoVersion: 0,
|
||||||
|
status: {
|
||||||
|
checkedAt: null,
|
||||||
|
reachable: true,
|
||||||
|
maintenance: false,
|
||||||
|
needsDbUpgrade: false,
|
||||||
|
versionString: version,
|
||||||
|
edition: null,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
},
|
||||||
|
rating: { level, reason: 'current', updateTo: null, eolDate, cycle: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = (list: NextcloudInstance[]) => list.map((i) => i.customerName);
|
||||||
|
|
||||||
|
describe('sortClouds', () => {
|
||||||
|
it('name: A-Z mit deutscher Sortierung, ohne Beachtung der Gross-/Kleinschreibung', () => {
|
||||||
|
const list = [make('zebra'), make('Ärger'), make('Anton'), make('berta')];
|
||||||
|
expect(names(sortClouds(list, 'name'))).toEqual(['Anton', 'Ärger', 'berta', 'zebra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('status: rot, gelb, grau, gruen, bei Gleichstand nach Name', () => {
|
||||||
|
const list = [
|
||||||
|
make('g2', 'green'),
|
||||||
|
make('r2', 'red'),
|
||||||
|
make('u1', 'unknown'),
|
||||||
|
make('y1', 'yellow'),
|
||||||
|
make('r1', 'red'),
|
||||||
|
make('g1', 'green'),
|
||||||
|
];
|
||||||
|
expect(names(sortClouds(list, 'status'))).toEqual(['r1', 'r2', 'y1', 'u1', 'g1', 'g2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('version: aelteste zuerst, ohne Version zuletzt', () => {
|
||||||
|
const list = [
|
||||||
|
make('a', 'green', '34.0.10'),
|
||||||
|
make('b', 'green', null),
|
||||||
|
make('c', 'green', '33.0.9'),
|
||||||
|
make('d', 'green', '34.0.2'),
|
||||||
|
];
|
||||||
|
expect(names(sortClouds(list, 'version'))).toEqual(['c', 'd', 'a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eol: frueheste zuerst, ohne Datum zuletzt', () => {
|
||||||
|
const list = [
|
||||||
|
make('a', 'green', '1.0.0', '2027-06-30'),
|
||||||
|
make('b'),
|
||||||
|
make('c', 'green', '1.0.0', '2026-09-30'),
|
||||||
|
];
|
||||||
|
expect(names(sortClouds(list, 'eol'))).toEqual(['c', 'a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('veraendert die Eingabe nicht', () => {
|
||||||
|
const list = [make('b'), make('a')];
|
||||||
|
const copy = [...list];
|
||||||
|
sortClouds(list, 'name');
|
||||||
|
expect(list).toEqual(copy);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sortier-Vorliebe', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merkt die Auswahl je Benutzer unter eigenem Schluessel', () => {
|
||||||
|
writeSortPreference('u1', 'status');
|
||||||
|
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
|
||||||
|
expect(readSortPreference('u1')).toBe('status');
|
||||||
|
expect(readSortPreference('u2')).toBe('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein unbekannter gespeicherter Wert ergibt name', () => {
|
||||||
|
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'quatsch');
|
||||||
|
expect(readSortPreference('u1')).toBe('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein gesperrter Speicher wirft nie', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('gesperrt');
|
||||||
|
});
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('gesperrt');
|
||||||
|
});
|
||||||
|
expect(readSortPreference('u1')).toBe('name');
|
||||||
|
expect(() => writeSortPreference('u1', 'eol')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sortierung der Cloud-Kacheln (quick-261002-k67, L-07, D-G) — reine
|
||||||
|
* Funktionen, kein React.
|
||||||
|
*/
|
||||||
|
export type SortKey = 'name' | 'status' | 'version' | 'eol';
|
||||||
|
|
||||||
|
export const SORT_KEYS: readonly SortKey[] = ['name', 'status', 'version', 'eol'];
|
||||||
|
|
||||||
|
/** Rot zuerst, dann Gelb, Grau, Gruen. */
|
||||||
|
const LEVEL_ORDER: Record<RatingLevel, number> = { red: 0, yellow: 1, unknown: 2, green: 3 };
|
||||||
|
|
||||||
|
function byName(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||||
|
return a.customerName.localeCompare(b.customerName, 'de', { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionTriple(raw: string | null): [number, number, number] | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
|
||||||
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||||
|
const va = versionTriple(a.status.versionString);
|
||||||
|
const vb = versionTriple(b.status.versionString);
|
||||||
|
if (!va && !vb) return 0;
|
||||||
|
if (!va) return 1; // unbekannt zuletzt
|
||||||
|
if (!vb) return -1;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (va[i] !== vb[i]) return va[i] - vb[i];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareEol(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||||
|
const ea = a.rating.eolDate;
|
||||||
|
const eb = b.rating.eolDate;
|
||||||
|
if (!ea && !eb) return 0;
|
||||||
|
if (!ea) return 1; // unbekannt zuletzt
|
||||||
|
if (!eb) return -1;
|
||||||
|
return ea < eb ? -1 : ea > eb ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert eine neue, sortierte Liste; die Eingabe bleibt unveraendert. Gleiche Werte nach Kundenname. */
|
||||||
|
export function sortClouds(items: NextcloudInstance[], key: SortKey): NextcloudInstance[] {
|
||||||
|
const compare = (a: NextcloudInstance, b: NextcloudInstance): number => {
|
||||||
|
switch (key) {
|
||||||
|
case 'status':
|
||||||
|
return LEVEL_ORDER[a.rating.level] - LEVEL_ORDER[b.rating.level];
|
||||||
|
case 'version':
|
||||||
|
return compareVersions(a, b);
|
||||||
|
case 'eol':
|
||||||
|
return compareEol(a, b);
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return [...items].sort((a, b) => compare(a, b) || byName(a, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(userId: string): string {
|
||||||
|
return `tessera:nextcloud-status:sort:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gemerkte Sortierung dieses Benutzers; unbekannter Wert oder gesperrter Speicher ergeben 'name'. */
|
||||||
|
export function readSortPreference(userId: string): SortKey {
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(storageKey(userId));
|
||||||
|
return (SORT_KEYS as readonly string[]).includes(stored ?? '') ? (stored as SortKey) : 'name';
|
||||||
|
} catch {
|
||||||
|
return 'name';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merkt die Sortierung; ein gesperrter Speicher wird still uebergangen. */
|
||||||
|
export function writeSortPreference(userId: string, key: SortKey): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey(userId), key);
|
||||||
|
} catch {
|
||||||
|
/* Speicher gesperrt oder voll — die Auswahl gilt dann nur fuer diese Sitzung */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,3 +84,93 @@ export function logoSrc(
|
|||||||
}
|
}
|
||||||
return instance.logoUrl ?? null;
|
return instance.logoUrl ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Liest die NestJS-Fehlermeldung aus dem Antwortkoerper, faellt sonst auf einen Standardtext zurueck. */
|
||||||
|
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (typeof body?.message === 'string') return body.message;
|
||||||
|
if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]);
|
||||||
|
} catch {
|
||||||
|
/* Antwort war kein JSON — Standardtext bleibt */
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextcloudInstancePayload {
|
||||||
|
customerName: string;
|
||||||
|
baseUrl: string;
|
||||||
|
/** Leere Zeichenkette = Logo-Adresse entfernen (nur beim Aendern). */
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = `${API_URL}/modules/nextcloud-status/instances`;
|
||||||
|
|
||||||
|
/** POST /instances — Cloud anlegen und sofort pruefen (nur Verwalten). */
|
||||||
|
export async function createInstance(
|
||||||
|
payload: NextcloudInstancePayload,
|
||||||
|
): Promise<NextcloudInstance> {
|
||||||
|
const res = await fetch(BASE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to create nextcloud instance'));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /instances/:id — Cloud aendern (nur Verwalten). */
|
||||||
|
export async function updateInstance(
|
||||||
|
id: string,
|
||||||
|
payload: Partial<NextcloudInstancePayload>,
|
||||||
|
): Promise<NextcloudInstance> {
|
||||||
|
const res = await fetch(`${BASE}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to update nextcloud instance'));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /instances/:id — Cloud entfernen (nur Verwalten). */
|
||||||
|
export async function deleteInstance(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to delete nextcloud instance'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /instances/check — "Jetzt pruefen" fuer die ganze Liste, liefert die frische Liste. */
|
||||||
|
export async function checkAll(): Promise<NextcloudList> {
|
||||||
|
const res = await fetch(`${BASE}/check`, { method: 'POST', credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instances'));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /instances/:id/check — eine Cloud sofort pruefen, liefert die aktualisierte Kachel. */
|
||||||
|
export async function checkOne(id: string): Promise<NextcloudInstance> {
|
||||||
|
const res = await fetch(`${BASE}/${id}/check`, { method: 'POST', credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instance'));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /instances/:id/logo — Logo hochladen (Formularfeld `logo`, nur Verwalten). */
|
||||||
|
export async function uploadLogo(id: string, file: File): Promise<NextcloudInstance> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('logo', file);
|
||||||
|
const res = await fetch(`${BASE}/${id}/logo`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to upload logo'));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /instances/:id/logo — hochgeladenes Logo entfernen (nur Verwalten). */
|
||||||
|
export async function removeLogo(id: string): Promise<NextcloudInstance> {
|
||||||
|
const res = await fetch(`${BASE}/${id}/logo`, { method: 'DELETE', credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo'));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1851,6 +1851,50 @@
|
|||||||
"noReference": "Bewertung nicht möglich",
|
"noReference": "Bewertung nicht möglich",
|
||||||
"versionUnknown": "Version unbekannt",
|
"versionUnknown": "Version unbekannt",
|
||||||
"notChecked": "Noch nicht geprüft"
|
"notChecked": "Noch nicht geprüft"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"label": "Sortieren nach",
|
||||||
|
"name": "Kundenname",
|
||||||
|
"status": "Status",
|
||||||
|
"version": "Version",
|
||||||
|
"eol": "Support-Ende"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"add": "Cloud hinzufügen",
|
||||||
|
"checkAll": "Jetzt prüfen",
|
||||||
|
"checking": "Wird geprüft …"
|
||||||
|
},
|
||||||
|
"cardActions": {
|
||||||
|
"refresh": "Diese Cloud jetzt prüfen",
|
||||||
|
"edit": "Cloud bearbeiten"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"addTitle": "Cloud hinzufügen",
|
||||||
|
"editTitle": "Cloud bearbeiten",
|
||||||
|
"customerName": "Kundenname",
|
||||||
|
"baseUrl": "Adresse der Cloud",
|
||||||
|
"baseUrlPlaceholder": "https://cloud.example.com",
|
||||||
|
"baseUrlHint": "Tessera ruft nur die öffentliche Statusseite dieser Adresse ab. Zugangsdaten werden nicht benötigt.",
|
||||||
|
"logo": "Logo",
|
||||||
|
"logoNone": "Kein Logo",
|
||||||
|
"logoUpload": "Bild hochladen",
|
||||||
|
"logoUrl": "Bildadresse (https)",
|
||||||
|
"logoUrlPlaceholder": "https://www.example.com/logo.png",
|
||||||
|
"logoUrlHint": "Die Adresse muss mit https:// beginnen. Das Bild wird direkt von Ihrem Browser geladen.",
|
||||||
|
"logoFileHint": "PNG, JPEG, GIF oder WebP, höchstens 1 MB.",
|
||||||
|
"logoFileTooLarge": "Die Datei ist größer als 1 MB.",
|
||||||
|
"logoCurrent": "Aktuelles Logo",
|
||||||
|
"logoRemove": "Logo entfernen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Wird geprüft …",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Cloud entfernen",
|
||||||
|
"deleteConfirm": "Möchten Sie die Cloud „{name}“ wirklich entfernen?",
|
||||||
|
"deleteConfirmYes": "Endgültig entfernen",
|
||||||
|
"required": "Bitte füllen Sie Kundenname und Adresse aus.",
|
||||||
|
"saveError": "Die Cloud konnte nicht gespeichert werden.",
|
||||||
|
"deleteError": "Die Cloud konnte nicht entfernt werden.",
|
||||||
|
"close": "Dialog schließen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1851,6 +1851,50 @@
|
|||||||
"noReference": "Rating not possible",
|
"noReference": "Rating not possible",
|
||||||
"versionUnknown": "Version unknown",
|
"versionUnknown": "Version unknown",
|
||||||
"notChecked": "Not checked yet"
|
"notChecked": "Not checked yet"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"label": "Sort by",
|
||||||
|
"name": "Customer name",
|
||||||
|
"status": "Status",
|
||||||
|
"version": "Version",
|
||||||
|
"eol": "End of support"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"add": "Add cloud",
|
||||||
|
"checkAll": "Check now",
|
||||||
|
"checking": "Checking …"
|
||||||
|
},
|
||||||
|
"cardActions": {
|
||||||
|
"refresh": "Check this cloud now",
|
||||||
|
"edit": "Edit cloud"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"addTitle": "Add cloud",
|
||||||
|
"editTitle": "Edit cloud",
|
||||||
|
"customerName": "Customer name",
|
||||||
|
"baseUrl": "Cloud address",
|
||||||
|
"baseUrlPlaceholder": "https://cloud.example.com",
|
||||||
|
"baseUrlHint": "Tessera only reads the public status page of this address. No login data is needed.",
|
||||||
|
"logo": "Logo",
|
||||||
|
"logoNone": "No logo",
|
||||||
|
"logoUpload": "Upload image",
|
||||||
|
"logoUrl": "Image address (https)",
|
||||||
|
"logoUrlPlaceholder": "https://www.example.com/logo.png",
|
||||||
|
"logoUrlHint": "The address must start with https://. The image is loaded directly by your browser.",
|
||||||
|
"logoFileHint": "PNG, JPEG, GIF or WebP, at most 1 MB.",
|
||||||
|
"logoFileTooLarge": "The file is larger than 1 MB.",
|
||||||
|
"logoCurrent": "Current logo",
|
||||||
|
"logoRemove": "Remove logo",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Checking …",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Remove cloud",
|
||||||
|
"deleteConfirm": "Do you really want to remove the cloud “{name}”?",
|
||||||
|
"deleteConfirmYes": "Remove permanently",
|
||||||
|
"required": "Please fill in customer name and address.",
|
||||||
|
"saveError": "The cloud could not be saved.",
|
||||||
|
"deleteError": "The cloud could not be removed.",
|
||||||
|
"close": "Close dialog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
|
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
|
||||||
'Neueste',
|
'Neueste',
|
||||||
'ausstehend',
|
'ausstehend',
|
||||||
|
'Statusseite',
|
||||||
|
'Bildadresse',
|
||||||
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
|
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
|
||||||
'Aussteller',
|
'Aussteller',
|
||||||
'Betriebssystemen',
|
'Betriebssystemen',
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user