feat(nextcloud-files): Modul Dateien mit Nextcloud-Adresse, Verbindungsprüfung und gesicherter Verbindungsschicht

- Migration 20261008180000: NextcloudFilesConfig (Organisation) und NextcloudFilesAccount
  (Mandant UND Benutzer per Zeilenschutz, nur verschlüsseltes App-Passwort)
- Transportschicht ncRequest: feste Pfadanfänge, Segmentcodierung, keine Weiterleitungen,
  keine Cookies, Zeitgrenzen; Aufrufsperre pausiert den Ursprung nach jedem 429 und
  sperrt Zugangsschlüssel nach dem ersten 401
- Einstellungen: Adresse speichern/prüfen, Adresswechsel läuft alle Konten ab (mit Bestätigung)
- Controller mit Verwalten nur auf Einstellungen, Seed, Modul, Registrierung in Web
  (Ladefunktion, Symbol folder, Navigation, Layout) und Reiter Einstellungen
- RLS-Inventar fortgeschrieben, Testaufbau (tessera-nc-test) und E2E-Skripte

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:41:46 +02:00
parent 83b842b72c
commit 960696745f
35 changed files with 3282 additions and 2 deletions
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import {
DEAD_KEY_TTL_MS,
DEFAULT_PAUSE_SECONDS,
MAX_DEAD_KEYS,
MAX_PAUSE_SECONDS,
NextcloudCallGate,
} from './nextcloud-call-gate';
function makeGate() {
const gate = new NextcloudCallGate();
const clock = { t: 1_000_000 };
gate.now = () => clock.t;
return { gate, clock };
}
describe('NextcloudCallGate — Sperre je Ursprung', () => {
it('nach pause(): 900 Sekunden, nur fuer diesen Ursprung', () => {
const { gate } = makeGate();
expect(gate.isPaused('https://cloud.example')).toEqual({ paused: false, retryAfterSeconds: 0 });
gate.pause('https://cloud.example');
expect(gate.isPaused('https://cloud.example')).toEqual({
paused: true,
retryAfterSeconds: DEFAULT_PAUSE_SECONDS,
});
expect(DEFAULT_PAUSE_SECONDS).toBe(900);
expect(gate.isPaused('https://other.example').paused).toBe(false);
});
it('Retry-After 120 -> 120 Sekunden; 99999 -> auf 3600 gedeckelt; Unsinn -> 900', () => {
const { gate } = makeGate();
gate.pause('https://a.example', '120');
expect(gate.isPaused('https://a.example').retryAfterSeconds).toBe(120);
gate.pause('https://b.example', '99999');
expect(gate.isPaused('https://b.example').retryAfterSeconds).toBe(3600);
expect(MAX_PAUSE_SECONDS).toBe(3600);
gate.pause('https://c.example', 'Wed, 21 Oct 2026 07:28:00 GMT');
expect(gate.isPaused('https://c.example').retryAfterSeconds).toBe(900);
gate.pause('https://d.example', '0');
expect(gate.isPaused('https://d.example').retryAfterSeconds).toBe(900);
});
it('nach Ablauf geht es wieder, die Restzeit sinkt mit der Uhr', () => {
const { gate, clock } = makeGate();
gate.pause('https://cloud.example', '120');
clock.t += 60_000;
expect(gate.isPaused('https://cloud.example')).toEqual({ paused: true, retryAfterSeconds: 60 });
clock.t += 60_000;
expect(gate.isPaused('https://cloud.example').paused).toBe(false);
});
it('eine kuerzere zweite Sperre verkuerzt eine laengere nicht', () => {
const { gate } = makeGate();
gate.pause('https://cloud.example', '600');
gate.pause('https://cloud.example', '60');
expect(gate.isPaused('https://cloud.example').retryAfterSeconds).toBe(600);
});
});
describe('NextcloudCallGate — Sperre je Zugangsschluessel', () => {
it('markDead(): Schluessel tot, andere Schluessel nicht', () => {
const { gate } = makeGate();
expect(gate.isDead('k1')).toBe(false);
gate.markDead('k1');
expect(gate.isDead('k1')).toBe(true);
expect(gate.isDead('k2')).toBe(false);
});
it('markDead() bricht das Signal des Schluessels ab, nicht das eines anderen', () => {
const { gate } = makeGate();
const s1 = gate.signalFor('k1');
const s2 = gate.signalFor('k2');
expect(s1.aborted).toBe(false);
gate.markDead('k1');
expect(s1.aborted).toBe(true);
expect(s2.aborted).toBe(false);
expect(gate.signalFor('k1').aborted).toBe(true);
});
it('tote Schluessel laufen nach 24 Stunden ab', () => {
const { gate, clock } = makeGate();
gate.markDead('k1');
clock.t += DEAD_KEY_TTL_MS - 1;
expect(gate.isDead('k1')).toBe(true);
clock.t += 2;
expect(gate.isDead('k1')).toBe(false);
});
it('haelt nie mehr als 10000 tote Schluessel, die aeltesten fliegen zuerst raus', () => {
const { gate } = makeGate();
for (let i = 0; i < MAX_DEAD_KEYS + 5; i++) gate.markDead(`key-${i}`);
expect(gate.isDead('key-0')).toBe(false);
expect(gate.isDead('key-4')).toBe(false);
expect(gate.isDead('key-5')).toBe(true);
expect(gate.isDead(`key-${MAX_DEAD_KEYS + 4}`)).toBe(true);
// intern gezaehlt: genau MAX_DEAD_KEYS Schluessel sind noch tot
let dead = 0;
for (let i = 0; i < MAX_DEAD_KEYS + 5; i++) if (gate.isDead(`key-${i}`)) dead++;
expect(dead).toBe(MAX_DEAD_KEYS);
});
});