fix(nextcloud-files): WR-01 Adresspruefung folgt keiner Weiterleitung und meldet nur grob

- testAddress fragt status.php ueber ncRequest ab (keine Weiterleitung, Aufrufsperre,
  keine Zugangsdaten) statt ueber fetchNextcloudStatus mit bis zu drei Weiterleitungen
- Ergebnis nur noch ok / maintenance / paused / locked / redirect / not-nextcloud /
  unreachable, ohne HTTP-Statuscode, Fehlerkuerzel oder Weiterleitungsziel
- NEXTCLOUD_STATUS_FETCHER entfaellt; Specs mit Attrappen-Transport, Anleitung angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:32:38 +02:00
parent e78e059477
commit 74e086f435
4 changed files with 158 additions and 183 deletions
@@ -4,49 +4,37 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
}));
import type { NextcloudCheckResult } from '../nextcloud-status/nextcloud-status-fetch';
import { Readable } from 'node:stream';
import { NextcloudCallGate } from './nextcloud-call-gate';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
import type { NcTransportRequest, NcTransportResponse } from './nextcloud-http';
interface Row {
baseUrl: string;
}
function reachable(over: Partial<NextcloudCheckResult> = {}): NextcloudCheckResult {
return {
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '34.0.4',
edition: '',
productName: 'Nextcloud',
errorKind: null,
errorDetail: null,
...over,
};
const STATUS_OK = {
installed: true,
maintenance: false,
needsDbUpgrade: false,
version: '34.0.4.1',
versionstring: '34.0.4',
edition: '',
productname: 'Nextcloud',
};
function reply(
statusCode: number,
body: unknown = '',
headers: Record<string, string> = {},
): NcTransportResponse {
const text = typeof body === 'string' ? body : JSON.stringify(body);
return { statusCode, headers, body: Readable.from(text === '' ? [] : [Buffer.from(text)]) };
}
function unreachable(
errorKind: NextcloudCheckResult['errorKind'],
errorDetail: string | null = null,
): NextcloudCheckResult {
return {
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind,
errorDetail,
};
}
type Handler = (req: NcTransportRequest) => NcTransportResponse;
function makeService(opts: {
row?: Row | null;
activeAccounts?: number;
check?: NextcloudCheckResult;
}) {
function makeService(opts: { row?: Row | null; activeAccounts?: number; handler?: Handler }) {
const state = { row: opts.row ?? null };
const upsert = vi.fn(async ({ create, update }: any) => {
state.row = state.row ? { ...state.row, ...update } : { ...create };
@@ -64,10 +52,14 @@ function makeService(opts: {
};
},
};
const fetcher = vi.fn(async (..._a: unknown[]) => opts.check ?? reachable());
const calls: NcTransportRequest[] = [];
const transport = vi.fn(async (req: NcTransportRequest) => {
calls.push(req);
return (opts.handler ?? (() => reply(200, STATUS_OK)))(req);
});
const gate = new NextcloudCallGate();
const service = new NextcloudFilesSettingsService(db as any, gate, fetcher as any);
return { service, state, upsert, updateMany, count, fetcher, gate, tenants };
const service = new NextcloudFilesSettingsService(db as any, gate, transport);
return { service, state, upsert, updateMany, count, calls, transport, gate, tenants };
}
const codeOf = async (p: Promise<unknown>) => {
@@ -163,7 +155,7 @@ describe('NextcloudFilesSettingsService — Speichern', () => {
});
it('mit confirmReconnect: Adresse gespeichert, alle Konten der Organisation abgelaufen, Zuhoerer gerufen', async () => {
const { service, upsert, updateMany, fetcher } = makeService({
const { service, upsert, updateMany, calls } = makeService({
row: { baseUrl: 'https://alt.example' },
activeAccounts: 2,
});
@@ -182,8 +174,8 @@ describe('NextcloudFilesSettingsService — Speichern', () => {
});
expect(listener).toHaveBeenCalledWith('t1');
expect(other).toHaveBeenCalledWith('t1');
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[0][0]).toBe('https://neu.example');
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('https://neu.example/status.php');
expect(view.check?.ok).toBe(true);
expect(view.connectedAccounts).toBe(0);
});
@@ -195,9 +187,9 @@ describe('NextcloudFilesSettingsService — Speichern', () => {
});
});
describe('NextcloudFilesSettingsService — Verbindungspruefung', () => {
it('erreichbar: ok mit Version', async () => {
const { service, fetcher } = makeService({ check: reachable() });
describe('NextcloudFilesSettingsService — Verbindungspruefung (WR-01: grob, ohne Weiterleitung)', () => {
it('erreichbar: ok mit Version, ein einziger GET auf status.php ohne Zugangsdaten', async () => {
const { service, calls } = makeService({});
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({
ok: true,
@@ -206,26 +198,57 @@ describe('NextcloudFilesSettingsService — Verbindungspruefung', () => {
productName: 'Nextcloud',
});
expect(res.message).toContain('34.0.4');
expect(fetcher.mock.calls[0][0]).toBe('https://cloud.example');
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('GET');
expect(calls[0].url).toBe('https://cloud.example/status.php');
expect(calls[0].headers.authorization).toBeUndefined();
expect(calls[0].headers.cookie).toBeUndefined();
});
it('HTTP 400 ist der Hinweis auf die vertrauenswuerdigen Domains', async () => {
const { service } = makeService({ check: unreachable('http-status', 'HTTP 400') });
it('eine Weiterleitung wird NICHT befolgt; das Ergebnis nennt weder Ziel noch Status', async () => {
const { service, calls } = makeService({
handler: () => reply(302, '', { location: 'http://10.0.0.7:8080/secret' }),
});
const res = await service.testAddress('https://cloud.example');
expect(res.ok).toBe(false);
expect(res.kind).toBe('trusted-domains');
expect(res.message).toContain('vertrauenswürdigen Domains');
expect(res).toMatchObject({ ok: false, kind: 'redirect' });
expect(calls).toHaveLength(1);
const text = JSON.stringify(res);
expect(text).not.toContain('10.0.0.7');
expect(text).not.toContain('302');
});
it('Zeitueberschreitung hat einen deutschen Text', async () => {
const { service } = makeService({ check: unreachable('timeout') });
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({ ok: false, kind: 'timeout' });
expect(res.message).toContain('nicht rechtzeitig');
it('jeder andere HTTP-Fehler heisst nur "keine Nextcloud", ohne Statuscode', async () => {
for (const status of [400, 401, 403, 404, 500, 502]) {
const { service } = makeService({ handler: () => reply(status, 'interne Seite') });
const res = await service.testAddress('https://cloud.example');
expect(res.kind, String(status)).toBe('not-nextcloud');
expect(JSON.stringify(res)).not.toContain(String(status));
expect(JSON.stringify(res)).not.toContain('interne Seite');
}
});
it('keine Nextcloud-Antwort (200 ohne installed:true) ist not-nextcloud', async () => {
const { service } = makeService({ handler: () => reply(200, '<html>Router</html>') });
expect((await service.testAddress('https://cloud.example')).kind).toBe('not-nextcloud');
});
it('Netzfehler, Zeitueberschreitung und Zertifikat sind gleichermassen "nicht erreichbar"', async () => {
for (const code of ['ECONNREFUSED', 'UND_ERR_HEADERS_TIMEOUT', 'CERT_HAS_EXPIRED']) {
const { service } = makeService({
handler: () => {
throw Object.assign(new Error('x'), { code });
},
});
const res = await service.testAddress('https://cloud.example');
expect(res, code).toMatchObject({ ok: false, kind: 'unreachable' });
expect(JSON.stringify(res)).not.toContain(code);
}
});
it('Wartungsmodus ist kein Erfolg', async () => {
const { service } = makeService({ check: reachable({ maintenance: true }) });
const { service } = makeService({
handler: () => reply(200, { ...STATUS_OK, maintenance: true }),
});
expect(await service.testAddress('https://cloud.example')).toMatchObject({
ok: false,
kind: 'maintenance',
@@ -233,15 +256,15 @@ describe('NextcloudFilesSettingsService — Verbindungspruefung', () => {
});
it('eine gesperrte Adresse (Aufrufsperre) fragt Nextcloud gar nicht erst', async () => {
const { service, fetcher, gate } = makeService({});
const { service, calls, gate } = makeService({});
gate.pause('https://cloud.example');
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({ ok: false, kind: 'paused' });
expect(fetcher).not.toHaveBeenCalled();
expect(calls).toHaveLength(0);
});
it('ein 429 aus der Pruefung sperrt den Ursprung fuer alle weiteren Aufrufe', async () => {
const { service, gate } = makeService({ check: unreachable('http-status', 'HTTP 429') });
const { service, gate } = makeService({ handler: () => reply(429) });
const res = await service.testAddress('https://cloud.example');
expect(res.kind).toBe('locked');
expect(gate.isPaused('https://cloud.example').paused).toBe(true);
@@ -1,13 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { fetch as undiciFetch } from 'undici';
import {
normalizeCloudUrl,
parseNextcloudStatus,
} from '../nextcloud-status/nextcloud-status-fetch';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
type FetchStatusOptions,
fetchNextcloudStatus,
type NextcloudCheckResult,
normalizeCloudUrl,
} from '../nextcloud-status/nextcloud-status-fetch';
import { NextcloudCallGate } from './nextcloud-call-gate';
import {
type NextcloudFilesCheckView,
@@ -15,14 +12,17 @@ import {
type NextcloudFilesStatusView,
ncErrorDefault,
} from './nextcloud-files.types';
import {
discardBody,
NEXTCLOUD_TRANSPORT,
type NextcloudTransport,
ncRequest,
readCappedText,
} from './nextcloud-http';
/** Nest-Token fuer den Abruf von `status.php` (Tests setzen eine Attrappe ein). */
export const NEXTCLOUD_STATUS_FETCHER = 'NEXTCLOUD_STATUS_FETCHER';
export type NextcloudStatusFetcher = (
baseUrl: string,
opts?: FetchStatusOptions,
) => Promise<NextcloudCheckResult>;
export const defaultStatusFetcher: NextcloudStatusFetcher = fetchNextcloudStatus;
/** Zeitgrenze und Groessendeckel der Adresspruefung (`status.php`). */
const CHECK_TIMEOUT_MS = 10_000;
const CHECK_MAX_BYTES = 64 * 1024;
export type AddressChangeListener = (tenantId: string) => void;
@@ -30,8 +30,12 @@ interface ConfigRow {
baseUrl: string;
}
const TRUSTED_DOMAINS_TEXT =
'Nextcloud lehnt diese Adresse ab. Bitte nehmen Sie den Rechnernamen in die vertrauenswürdigen Domains (trusted_domains) der Nextcloud auf.';
const NOT_NEXTCLOUD_TEXT =
'Unter dieser Adresse antwortet keine Nextcloud. Steht dort doch eine Nextcloud, nehmen Sie bitte den Rechnernamen in ihre vertrauenswürdigen Domains (trusted_domains) auf.';
const UNREACHABLE_TEXT =
'Unter dieser Adresse ist keine Nextcloud erreichbar. Bitte prüfen Sie die Adresse. Bei https muss der Tessera-Server dem Zertifikat vertrauen (eigene Zertifizierungsstelle siehe Betriebshandbuch).';
const REDIRECT_TEXT =
'Die Adresse leitet auf eine andere Adresse weiter. Tessera folgt keiner Weiterleitung; tragen Sie bitte die endgültige Adresse ein (zum Beispiel mit https).';
function hostOf(baseUrl: string): string | null {
try {
@@ -41,14 +45,6 @@ function hostOf(baseUrl: string): string | null {
}
}
function originOf(baseUrl: string): string | null {
try {
return new URL(baseUrl).origin;
} catch {
return null;
}
}
/**
* Einstellungen des Moduls "Nextcloud-Dateien" (quick-261008-mzu): die EINE
* Nextcloud-Adresse der Organisation. Gesamter Zugriff auf `nextcloudFilesConfig`
@@ -67,7 +63,7 @@ export class NextcloudFilesSettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_STATUS_FETCHER) private readonly statusFetcher: NextcloudStatusFetcher,
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
) {}
/** Meldet einen Zuhoerer an, der nach einem Adresswechsel mit der Organisation aufgerufen wird. */
@@ -149,48 +145,20 @@ export class NextcloudFilesSettingsService {
// --- Verbindungspruefung -----------------------------------------------------
/**
* Fragt `status.php` der Adresse ab (ohne Zugangsdaten). Antwortet immer mit
* `{ ok, kind, message, version, productName }`; Netz- und HTTP-Probleme sind
* keine Serverfehler. Steht der Ursprung wegen eines 429 auf der Aufrufsperre,
* geht gar keine Anfrage raus.
* Fragt `status.php` der Adresse ab (ohne Zugangsdaten) — ueber `ncRequest`, also
* mit denselben Regeln wie jeder andere Nextcloud-Aufruf: KEINE Weiterleitung wird
* befolgt, und steht der Ursprung wegen eines 429 auf der Aufrufsperre, geht gar
* keine Anfrage raus.
*
* Das Ergebnis ist bewusst GROB (WR-01): erreichbare Nextcloud (`ok`), Wartung,
* gesperrt, Weiterleitung auf eine andere Adresse (`redirect`), keine Nextcloud
* (`not-nextcloud`) oder nicht erreichbar (`unreachable`). Kein HTTP-Statuscode,
* kein Fehlerkuerzel, kein Ziel einer Weiterleitung verlaesst den Server — die
* Pruefung taugt so nicht zum Abtasten interner Adressen und Dienste.
*/
async testAddress(rawBaseUrl: string): Promise<NextcloudFilesCheckView> {
const baseUrl = normalizeCloudUrl(rawBaseUrl);
if (baseUrl === null) throw ncErrorDefault('invalidUrl');
const origin = originOf(baseUrl);
if (origin) {
const pause = this.gate.isPaused(origin);
if (pause.paused) {
const minutes = Math.max(1, Math.ceil(pause.retryAfterSeconds / 60));
return {
ok: false,
kind: 'paused',
message: `Nextcloud sperrt Anmeldungen vom Tessera-Server zurzeit. Bitte warten Sie etwa ${minutes} Minuten.`,
version: null,
productName: null,
};
}
}
// Merkt, ob die Adresse nur ueber eine Weiterleitung antwortet: im Betrieb folgt Tessera
// keiner Weiterleitung, die Adresse muss dann direkt eingetragen werden.
let redirected = false;
const recordingFetch = (async (...args: Parameters<typeof undiciFetch>) => {
const response = await undiciFetch(...args);
if (response.status >= 300 && response.status < 400) redirected = true;
return response;
}) as typeof undiciFetch;
const result = await this.statusFetcher(baseUrl, { fetchImpl: recordingFetch });
return this.describeCheck(result, redirected, origin);
}
private describeCheck(
result: NextcloudCheckResult,
redirected: boolean,
origin: string | null,
): NextcloudFilesCheckView {
const fail = (kind: string, message: string): NextcloudFilesCheckView => ({
ok: false,
kind,
@@ -199,64 +167,53 @@ export class NextcloudFilesSettingsService {
productName: null,
});
if (result.reachable) {
if (result.maintenance) {
return fail('maintenance', 'Nextcloud befindet sich im Wartungsmodus.');
const res = await ncRequest(this.transport, this.gate, {
baseUrl,
prefix: '/status.php',
method: 'GET',
headers: { accept: 'application/json' },
headersTimeoutMs: CHECK_TIMEOUT_MS,
bodyTimeoutMs: CHECK_TIMEOUT_MS,
});
if (!res.ok) {
if (res.kind === 'paused' || (res.kind === 'http' && res.status === 429)) {
const minutes = Math.max(1, Math.ceil((res.retryAfterSeconds ?? 900) / 60));
return fail(
res.kind === 'paused' ? 'paused' : 'locked',
`Nextcloud sperrt Anmeldungen vom Tessera-Server zurzeit. Bitte warten Sie etwa ${minutes} Minuten.`,
);
}
const name = result.productName ?? 'Nextcloud';
const version = result.versionString;
const base = version
? `Verbindung erfolgreich: ${name} ${version}.`
: 'Verbindung erfolgreich.';
const hint = redirected
? ' Die Adresse leitet allerdings um. Tessera folgt keiner Weiterleitung, tragen Sie bitte die endgültige Adresse ein (zum Beispiel mit https).'
: '';
return {
ok: true,
kind: redirected ? 'redirected' : 'ok',
message: base + hint,
version,
productName: result.productName,
};
if (res.kind === 'redirect') return fail('redirect', REDIRECT_TEXT);
return fail('unreachable', UNREACHABLE_TEXT);
}
switch (result.errorKind) {
case 'timeout':
return fail(
'timeout',
'Nextcloud hat nicht rechtzeitig geantwortet. Bitte prüfen Sie die Adresse und versuchen Sie es erneut.',
);
case 'network':
return fail('network', 'Unter dieser Adresse ist keine Nextcloud erreichbar.');
case 'tls':
return fail(
'tls',
'Das Zertifikat der Nextcloud konnte nicht geprüft werden. Bitte prüfen Sie die Adresse oder hinterlegen Sie die Zertifizierungsstelle auf dem Tessera-Server.',
);
case 'http-status': {
if (result.errorDetail === 'HTTP 400') return fail('trusted-domains', TRUSTED_DOMAINS_TEXT);
if (result.errorDetail === 'HTTP 429') {
if (origin) this.gate.pause(origin);
return fail(
'locked',
'Nextcloud sperrt Anmeldungen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in einigen Minuten erneut.',
);
}
return fail(
'http-status',
`Nextcloud hat unter dieser Adresse mit einem Fehler geantwortet (${result.errorDetail ?? 'unbekannt'}).`,
);
}
case 'redirect':
return fail(
'redirect',
'Die Adresse leitet mehrfach um oder führt nicht zu einer Nextcloud. Bitte prüfen Sie sie.',
);
case 'too-large':
case 'not-nextcloud':
return fail('not-nextcloud', 'Unter dieser Adresse antwortet keine Nextcloud.');
default:
return fail('network', 'Unter dieser Adresse ist keine Nextcloud erreichbar.');
if (res.status === 503) {
discardBody(res.body);
return fail('maintenance', 'Nextcloud befindet sich im Wartungsmodus.');
}
if (res.status < 200 || res.status >= 300) {
discardBody(res.body);
return fail('not-nextcloud', NOT_NEXTCLOUD_TEXT);
}
const text = await readCappedText(res.body, CHECK_MAX_BYTES);
if (!text.ok) {
return text.kind === 'too-large'
? fail('not-nextcloud', NOT_NEXTCLOUD_TEXT)
: fail('unreachable', UNREACHABLE_TEXT);
}
const parsed = parseNextcloudStatus(text.text);
if (!parsed) return fail('not-nextcloud', NOT_NEXTCLOUD_TEXT);
if (parsed.maintenance) return fail('maintenance', 'Nextcloud befindet sich im Wartungsmodus.');
const name = parsed.productName ?? 'Nextcloud';
const version = parsed.versionString;
return {
ok: true,
kind: 'ok',
message: version ? `Verbindung erfolgreich: ${name} ${version}.` : 'Verbindung erfolgreich.',
version,
productName: parsed.productName,
};
}
}
@@ -6,11 +6,7 @@ import { NextcloudFilesController } from './nextcloud-files.controller';
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
import { NextcloudFilesService } from './nextcloud-files.service';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
import {
defaultStatusFetcher,
NEXTCLOUD_STATUS_FETCHER,
NextcloudFilesSettingsService,
} from './nextcloud-files-settings.service';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
@@ -36,7 +32,6 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
LoginFlowStore,
NextcloudCallGate,
{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport },
{ provide: NEXTCLOUD_STATUS_FETCHER, useValue: defaultStatusFetcher },
],
exports: [
NextcloudCallGate,