diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-connect.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-connect.sh new file mode 100755 index 0000000..3c4dd37 --- /dev/null +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-connect.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# End-zu-Ende-Pruefung Aufgabe 2 (quick-261008-mzu): Verbinden mit Passwort, Zwei-Faktor-Konto, +# Browser-Anmeldung (Start/Abfrage/Abbruch), Wiederverbinden ohne verwaiste Zugaenge, Trennen mit +# Widerruf. Voraussetzung: lokaler Stack laeuft (neu gebaut), nc-test-setup.sh ist gelaufen. +# Die Zwei-Faktor-Anmeldung im Browser (Zugriff gewaehren) belegt der Playwright-Lauf, nicht dieses Skript. +set -euo pipefail +# shellcheck source=e2e-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/e2e-lib.sh" + +JAR="$E2E_TMP/admin.jar" +C="$API/modules/nextcloud-files" + +e2e_login "$JAR" +e2e_activate "$JAR" +e2e_set_address "$JAR" + +# Sauberer Start: Verbindung trennen (200, oder 409 wenn nicht verbunden). +code=$(e2e_status "$JAR" DELETE "$C/connect") +case "$code" in 200) ;; 409) e2e_contains "$E2E_TMP/body.out" 'notConnected' "Start: notConnected" ;; *) e2e_fail "DELETE connect (Start) -> $code" ;; esac +e2e_expect 0 "$(e2e_nc_tokens anna)" "Start: keine Tessera-Zugaenge fuer anna" + +# 1. Verbinden mit Passwort +code=$(e2e_connect_anna "$JAR") +e2e_expect 200 "$code" "connect/password anna" +e2e_contains "$E2E_TMP/body.out" '"ncUserId":"anna"' "Antwort: ncUserId" +if grep -q -e 'User1-Pass' -e 'ncrypted' -e 'appPassword' "$E2E_TMP/body.out"; then e2e_fail "Antwort enthaelt ein Geheimnis"; fi + +code=$(e2e_status "$JAR" GET "$C/status") +e2e_expect 200 "$code" "GET status" +e2e_contains "$E2E_TMP/body.out" '"status":"ACTIVE"' "status ACTIVE" +if grep -q -e 'User1-Pass' -e 'ncrypted' "$E2E_TMP/body.out"; then e2e_fail "status enthaelt ein Geheimnis"; fi + +e2e_expect 1 "$(e2e_nc_tokens anna)" "genau ein Tessera-Zugang fuer anna" + +# 2. Wiederverbinden: der alte Zugang wird widerrufen, es bleibt genau einer +code=$(e2e_connect_anna "$JAR") +e2e_expect 200 "$code" "Wiederverbinden anna" +e2e_expect 1 "$(e2e_nc_tokens anna)" "nach Wiederverbinden genau ein Tessera-Zugang" + +# 2b. Anmeldung mit E-Mail-Adresse: das App-Passwort gehoert zum eingegebenen Anmeldenamen (gemessen: +# mit der Kennung als Basic-Benutzer antwortet Nextcloud 401). Der Widerruf beim Trennen gelingt nur, +# wenn Tessera den Anmeldenamen mitgespeichert hat; das belegt die Token-Zahl 0 danach. +NC_OCC user:setting anna settings email anna@example.com >/dev/null +code=$(e2e_status "$JAR" POST "$C/connect/password" '{"loginName":"anna@example.com","password":"User1-Pass-12345"}') +e2e_expect 200 "$code" "connect/password mit E-Mail" +e2e_contains "$E2E_TMP/body.out" '"ncUserId":"anna"' "E-Mail-Anmeldung: ncUserId ist die Kennung" +e2e_expect 1 "$(e2e_nc_tokens anna)" "E-Mail-Anmeldung: der alte Zugang wurde widerrufen" +code=$(e2e_status "$JAR" DELETE "$C/connect") +e2e_expect 200 "$code" "Trennen nach E-Mail-Anmeldung" +e2e_expect 0 "$(e2e_nc_tokens anna)" "Widerruf mit dem Anmeldenamen der Ausstellung" +code=$(e2e_connect_anna "$JAR") +e2e_expect 200 "$code" "wieder mit der Kennung verbinden" + +# 3. Zweiter Tessera-Benutzer derselben Organisation sieht das Konto nicht (Mandant UND Benutzer) +e2e_second_user e2euser "$E2E_TMP/e2euser.jar" +e2e_grant_use "$JAR" "$E2E_TMP/e2euser.jar" +code=$(e2e_status "$E2E_TMP/e2euser.jar" GET "$C/status") +e2e_expect 200 "$code" "e2euser GET status" +e2e_contains "$E2E_TMP/body.out" '"account":null' "e2euser sieht anna's Konto nicht (account null)" +code=$(e2e_status "$E2E_TMP/e2euser.jar" DELETE "$C/connect") +e2e_expect 409 "$code" "e2euser kann annas Konto nicht trennen" +e2e_contains "$E2E_TMP/body.out" 'notConnected' "e2euser: notConnected" +e2e_expect 1 "$(e2e_nc_tokens anna)" "annas Zugang bleibt nach dem Trennversuch von e2euser" + +# 4. Zwei-Faktor-Konto: 422 credentialsOrTwoFactor. Jeder Versuch zaehlt in Tessera als Fehlversuch +# (3 je Benutzer in 15 min); bei 429 startet die API neu und der Versuch wird einmal wiederholt. +zoe_attempt() { + e2e_status "$JAR" POST "$C/connect/password" '{"loginName":"zoe","password":"User2-Pass-12345"}' +} +code=$(zoe_attempt) +if [ "$code" = "429" ]; then + e2e_contains "$E2E_TMP/body.out" 'tooManyAttempts' "429 ist tooManyAttempts" + (cd "$E2E_DIR/../../../.." && docker compose restart api >/dev/null) + e2e_wait_health + e2e_login "$JAR" + code=$(zoe_attempt) +fi +e2e_expect 422 "$code" "zoe per Passwort" +e2e_contains "$E2E_TMP/body.out" 'credentialsOrTwoFactor' "zoe: credentialsOrTwoFactor" +e2e_expect 0 "$(e2e_nc_tokens zoe)" "zoe bekommt keinen Zugang" + +# 5. Browser-Anmeldung: Start, Abfrage (noch nicht bestaetigt), Abbruch +code=$(e2e_status "$JAR" POST "$C/connect/flow") +e2e_expect 200 "$code" "connect/flow Start" +e2e_contains "$E2E_TMP/body.out" "\"loginUrl\":\"$NC_BASE/index.php/login/v2/flow/" "loginUrl zeigt auf die konfigurierte Adresse" +if grep -qiE '"(token|poll)' "$E2E_TMP/body.out"; then e2e_fail "Flow-Antwort traegt Token oder Poll-Feld"; fi +FLOW_ID=$(python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1]))["flowId"])' "$E2E_TMP/body.out") + +code=$(e2e_status "$JAR" GET "$C/connect/flow/$FLOW_ID") +e2e_expect 200 "$code" "connect/flow Abfrage" +e2e_contains "$E2E_TMP/body.out" '"state":"pending"' "Abfrage: pending" + +# Ein zweiter Tessera-Benutzer kennt die Kennung nicht: 404. +code=$(e2e_status "$E2E_TMP/e2euser.jar" GET "$C/connect/flow/$FLOW_ID") +e2e_expect 404 "$code" "fremder Benutzer fragt Ablauf ab" +code=$(e2e_status "$E2E_TMP/e2euser.jar" DELETE "$C/connect/flow/$FLOW_ID") +e2e_expect 404 "$code" "fremder Benutzer bricht Ablauf ab" +code=$(e2e_status "$JAR" GET "$C/connect/flow/$FLOW_ID") +e2e_expect 200 "$code" "eigener Ablauf lebt nach dem fremden Versuch" + +code=$(e2e_status "$JAR" DELETE "$C/connect/flow/$FLOW_ID") +e2e_expect 200 "$code" "connect/flow Abbruch" +code=$(e2e_status "$JAR" GET "$C/connect/flow/$FLOW_ID") +e2e_expect 404 "$code" "abgebrochener Ablauf ist weg" + +# 6. Trennen mit Widerruf +e2e_expect 1 "$(e2e_nc_tokens anna)" "vor dem Trennen genau ein Zugang" +code=$(e2e_status "$JAR" DELETE "$C/connect") +e2e_expect 200 "$code" "DELETE connect" +e2e_expect 0 "$(e2e_nc_tokens anna)" "nach dem Trennen kein Tessera-Zugang mehr" +code=$(e2e_status "$JAR" GET "$C/status") +e2e_expect 200 "$code" "GET status nach Trennen" +e2e_contains "$E2E_TMP/body.out" '"account":null' "status: account null" + +echo "e2e connect ok" diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh index 4e7782b..3ff53f6 100755 --- a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh @@ -82,3 +82,49 @@ e2e_second_user() { e2e_expect 200 "$code" "Passwortwechsel $name" e2e_login "$jar" "$name" "$final" } + +# --- Aufgabe 2: Konten ------------------------------------------------------------------- + +NC_OCC() { docker exec -u www-data tessera-nc-test php occ "$@"; } + +# e2e_nc_tokens — Anzahl der Tessera-Zugaenge (Name enthaelt "Tessera") in der +# Nextcloud. Messweg: occ user:auth-tokens:list --output=json, Zeilen mit "Tessera" im Namen. +e2e_nc_tokens() { + NC_OCC user:auth-tokens:list "$1" --output=json 2>/dev/null \ + | python3 -I -c 'import json,sys +d=json.load(sys.stdin) +print(len([t for t in d if "Tessera" in str(t.get("name",""))]))' +} + +# e2e_connect_anna — verbindet anna (Passwort-Weg) im Tessera-Benutzer des . +# Gibt den HTTP-Status aus; der Antwortkoerper landet in $E2E_TMP/body.out. +e2e_connect_anna() { + e2e_status "$1" POST "$API/modules/nextcloud-files/connect/password" \ + '{"loginName":"anna","password":"User1-Pass-12345"}' +} + +# e2e_wait_health — wartet bis die API antwortet. +e2e_wait_health() { + local i + for i in $(seq 1 60); do + curl -sf "$API/health" >/dev/null 2>&1 && return 0 + sleep 2 + done + e2e_fail "API wurde nicht gesund" +} + +# e2e_grant_use [slug] — gibt dem Benutzer des zweiten die +# Freigabestufe Benutzen fuer das Modul (idempotent: ein vorhandener Grant ist in Ordnung). +e2e_grant_use() { + local admin_jar=$1 user_jar=$2 slug=${3:-nextcloud-files} code user_id module_id + code=$(e2e_status "$user_jar" GET "$API/auth/me" "" "$E2E_TMP/me.out") + e2e_expect 200 "$code" "auth/me" + user_id=$(python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$E2E_TMP/me.out") + e2e_status "$admin_jar" GET "$API/modules/catalog" "" "$E2E_TMP/catalog.out" >/dev/null + module_id=$(python3 -I -c 'import json,sys +for m in json.load(open(sys.argv[2])): + if m["slug"]==sys.argv[1]: print(m["id"]); break' "$slug" "$E2E_TMP/catalog.out") + code=$(e2e_status "$admin_jar" POST "$API/module-grants" \ + "{\"moduleId\":\"$module_id\",\"userId\":\"$user_id\",\"level\":\"USE\"}") + case "$code" in 200|201|409) ;; *) e2e_fail "Freigabe Benutzen -> $code" ;; esac +} diff --git a/apps/api/prisma/migrations/20261008183000_nextcloud_files_login_name/migration.sql b/apps/api/prisma/migrations/20261008183000_nextcloud_files_login_name/migration.sql new file mode 100644 index 0000000..94fe784 --- /dev/null +++ b/apps/api/prisma/migrations/20261008183000_nextcloud_files_login_name/migration.sql @@ -0,0 +1,13 @@ +-- 261008-mzu (Aufgabe 2) — Anmeldename des App-Passworts im Konto. +-- +-- Warum: Ein App-Passwort gehoert zu dem Anmeldenamen, mit dem es ausgestellt wurde +-- (gemessen gegen Nextcloud 34: wer sich mit seiner E-Mail-Adresse anmeldet, bekommt +-- einen Zugang, der NUR mit der E-Mail-Adresse als Basic-Benutzer funktioniert; mit der +-- Nextcloud-Kennung `ncUserId` antwortet Nextcloud 401). Die Kennung `ncUserId` bleibt +-- fuer die Dateipfade (`/remote.php/dav/files//`) zustaendig, der Basic-Benutzer +-- steht jetzt in `ncLoginName`. NULL (Konten vor dieser Aenderung) heisst: Basic-Benutzer +-- ist `ncUserId`. +-- +-- Nur eine zusaetzliche, optionale Spalte; die Zeilenschutz-Regeln der Tabelle bleiben +-- unveraendert. +ALTER TABLE "NextcloudFilesAccount" ADD COLUMN "ncLoginName" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 37fe3cd..a407e5a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -977,6 +977,8 @@ model NextcloudFilesAccount { user User @relation(fields: [userId], references: [id], onDelete: Cascade) baseUrl String ncUserId String + // Anmeldename, mit dem das App-Passwort ausgestellt wurde (Basic-Benutzer). NULL = ncUserId. + ncLoginName String? ncDisplayName String? encryptedAppPassword String status NextcloudFilesAccountStatus @default(ACTIVE) diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index 2722886..e7ccbbc 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -12,8 +12,8 @@ import { NextcloudFilesController } from '../nextcloud-files/nextcloud-files.con import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller'; import { ProxmoxController } from '../proxmox/proxmox.controller'; import { TendersController } from '../tenders/tenders.controller'; -import { ModuleRegistryController } from './module-registry.controller'; import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from './module.guard'; +import { ModuleRegistryController } from './module-registry.controller'; /** * Metadaten-Beweis für die Freigabestufe Verwalten (261002-icv, L-04/L-09): @@ -53,19 +53,25 @@ describe('Umgestellte Handler (Verwalten)', () => { expect(Reflect.getMetadata(MODULE_SLUG_KEY, DkvController)).toBe('dkv-fleet'); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, DkvController)).toBe(true); expect(Reflect.getMetadata(GUARDS_METADATA, DkvController)).toContain(ModuleGuard); - const names = methodsOf(DkvController).filter((n) => Reflect.hasMetadata('path', handler(DkvController, n))); + const names = methodsOf(DkvController).filter((n) => + Reflect.hasMetadata('path', handler(DkvController, n)), + ); expect(names.length).toBe(11); for (const name of names) { expect(Reflect.getMetadata(ROLES_KEY, handler(DkvController, name)), name).toBeUndefined(); } }); - it.each(['create', 'update', 'remove', 'poll', 'test', 'testDraft'])( - 'ProxmoxController.%s verlangt Verwalten für proxmox', - (name) => { - expectManage(ProxmoxController, name, 'proxmox'); - }, - ); + it.each([ + 'create', + 'update', + 'remove', + 'poll', + 'test', + 'testDraft', + ])('ProxmoxController.%s verlangt Verwalten für proxmox', (name) => { + expectManage(ProxmoxController, name, 'proxmox'); + }); it('ProxmoxController.list bleibt auf Benutzen-Ebene', () => { const fn = handler(ProxmoxController, 'list'); @@ -73,14 +79,25 @@ describe('Umgestellte Handler (Verwalten)', () => { expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); }); - it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])( - 'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status', - (name) => { - expectManage(NextcloudStatusController, name, 'nextcloud-status'); - }, - ); + it.each([ + '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', 'subscribe', 'unsubscribe', 'recentAlerts'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => { + it.each([ + 'list', + 'logo', + 'subscribe', + 'unsubscribe', + 'recentAlerts', + ])('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(); @@ -128,6 +145,11 @@ describe('Umgestellte Handler (Verwalten)', () => { // Spätere Aufgaben von quick-261008-mzu ergänzen diese Liste um ihre Benutzen-Handler. it.each([ 'getStatus', + 'connectPassword', + 'startFlow', + 'pollFlow', + 'cancelFlow', + 'disconnect', ])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => { const fn = handler(NextcloudFilesController, name); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); @@ -141,24 +163,27 @@ describe('Umgestellte Handler (Verwalten)', () => { }); describe('Bewusst nur für Administratoren (T-icv-01, T-icv-07)', () => { - it.each(['getSourceConfig', 'saveSourceConfig', 'pollNow'])( - 'TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', - (name) => { - expectAdminOnly(TendersController, name); - }, - ); + it.each([ + 'getSourceConfig', + 'saveSourceConfig', + 'pollNow', + ])('TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => { + expectAdminOnly(TendersController, name); + }); - it.each(['matrix', 'userAccess', 'create', 'remove'])( - 'ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', - (name) => { - expectAdminOnly(ModuleGrantsController, name); - }, - ); + it.each([ + 'matrix', + 'userAccess', + 'create', + 'remove', + ])('ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => { + expectAdminOnly(ModuleGrantsController, name); + }); - it.each(['activate', 'deactivate'])( - 'ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', - (name) => { - expectAdminOnly(ModuleRegistryController, name); - }, - ); + it.each([ + 'activate', + 'deactivate', + ])('ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => { + expectAdminOnly(ModuleRegistryController, name); + }); }); diff --git a/apps/api/src/nextcloud-files/dto/nextcloud-files-connect.dto.ts b/apps/api/src/nextcloud-files/dto/nextcloud-files-connect.dto.ts new file mode 100644 index 0000000..8a2a9ee --- /dev/null +++ b/apps/api/src/nextcloud-files/dto/nextcloud-files-connect.dto.ts @@ -0,0 +1,18 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** + * Anmeldung mit Nextcloud-Benutzername und Passwort (quick-261008-mzu). Das + * Passwort wird nur einmal an Nextcloud gesendet und nie gespeichert, geloggt + * oder zurueckgegeben. + */ +export class ConnectPasswordDto { + @IsString() + @IsNotEmpty() + @MaxLength(200) + loginName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(500) + password!: string; +} diff --git a/apps/api/src/nextcloud-files/nextcloud-auth-client.spec.ts b/apps/api/src/nextcloud-files/nextcloud-auth-client.spec.ts new file mode 100644 index 0000000..44be4de --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-auth-client.spec.ts @@ -0,0 +1,317 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { + authFailureToException, + getAppPassword, + getCurrentUser, + ocsRequest, + pollLoginFlow, + revokeAppPassword, + startLoginFlow, +} from './nextcloud-auth-client'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http'; + +const BASE = 'http://cloud.example'; +const TOKEN128 = 'A1b2C3d4'.repeat(16); // 128 alphanumerische Zeichen + +function reply( + statusCode: number, + body: unknown = '', + headers: Record = {}, +): NcTransportResponse { + const text = typeof body === 'string' ? body : JSON.stringify(body); + return { statusCode, headers, body: Readable.from(text === '' ? [] : [Buffer.from(text)]) }; +} + +function fake(handler: (req: NcTransportRequest) => NcTransportResponse) { + const calls: NcTransportRequest[] = []; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + return handler(req); + }; + return { transport, calls }; +} + +const ocs = (data: unknown) => ({ ocs: { meta: { status: 'ok' }, data } }); + +describe('getAppPassword', () => { + it('sendet genau GET getapppassword mit Basic-Anmeldung und OCS-Kopfzeilen', async () => { + const { transport, calls } = fake(() => reply(200, ocs({ apppassword: 'app-pw-123' }))); + const gate = new NextcloudCallGate(); + const res = await getAppPassword(transport, gate, BASE, 'anna', 'geheim'); + expect(res).toEqual({ ok: true, appPassword: 'app-pw-123' }); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('GET'); + expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/getapppassword'); + expect(calls[0].headers).toEqual({ + 'user-agent': 'Tessera (Nextcloud-Dateien)', + 'ocs-apirequest': 'true', + accept: 'application/json', + authorization: 'Basic YW5uYTpnZWhlaW0=', + }); + }); + + it('401: Art credentials, die Sperre markiert nichts', async () => { + const { transport } = fake(() => reply(401)); + const gate = new NextcloudCallGate(); + const res = await getAppPassword(transport, gate, BASE, 'anna', 'falsch'); + expect(res).toMatchObject({ ok: false, kind: 'credentials' }); + expect(gate.isPaused('http://cloud.example').paused).toBe(false); + }); + + it('403: app-password-given', async () => { + const { transport } = fake(() => reply(403)); + expect(await getAppPassword(transport, new NextcloudCallGate(), BASE, 'a', 'b')).toMatchObject({ + ok: false, + kind: 'app-password-given', + }); + }); + + it('429: locked, und die Sperre haelt den Ursprung an (naechster Aufruf ohne Transport)', async () => { + const { transport, calls } = fake(() => reply(429, '', { 'retry-after': '600' })); + const gate = new NextcloudCallGate(); + const first = await getAppPassword(transport, gate, BASE, 'a', 'b'); + expect(first).toMatchObject({ ok: false, kind: 'locked', retryAfterSeconds: 600 }); + const second = await getAppPassword(transport, gate, BASE, 'a', 'b'); + expect(second).toMatchObject({ ok: false, kind: 'locked' }); + expect(calls).toHaveLength(1); + }); + + it('503 ist Wartungsmodus, Antwort ohne apppassword ist invalid-response', async () => { + expect( + await getAppPassword( + fake(() => reply(503)).transport, + new NextcloudCallGate(), + BASE, + 'a', + 'b', + ), + ).toMatchObject({ ok: false, kind: 'maintenance' }); + expect( + await getAppPassword( + fake(() => reply(200, ocs({}))).transport, + new NextcloudCallGate(), + BASE, + 'a', + 'b', + ), + ).toMatchObject({ ok: false, kind: 'invalid-response' }); + }); + + it('eine Weiterleitung wird nicht befolgt: redirect', async () => { + const { transport, calls } = fake(() => reply(302, '', { location: 'http://evil.example/' })); + expect(await getAppPassword(transport, new NextcloudCallGate(), BASE, 'a', 'b')).toMatchObject({ + ok: false, + kind: 'redirect', + }); + expect(calls).toHaveLength(1); + }); +}); + +describe('getCurrentUser', () => { + it('liest id und display-name mit dem App-Passwort', async () => { + const { transport, calls } = fake(() => + reply(200, ocs({ id: 'anna', 'display-name': 'Anna Müller' })), + ); + const res = await getCurrentUser( + transport, + new NextcloudCallGate(), + BASE, + 'anna', + 'app-pw-123', + ); + expect(res).toEqual({ ok: true, id: 'anna', displayName: 'Anna Müller' }); + expect(calls[0].method).toBe('GET'); + expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/cloud/user'); + expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz'); + }); + + it('faellt auf displayname zurueck und erlaubt fehlenden Namen', async () => { + const a = await getCurrentUser( + fake(() => reply(200, ocs({ id: 'anna', displayname: 'Anna' }))).transport, + new NextcloudCallGate(), + BASE, + 'anna', + 'x', + ); + expect(a).toEqual({ ok: true, id: 'anna', displayName: 'Anna' }); + const b = await getCurrentUser( + fake(() => reply(200, ocs({ id: 'anna' }))).transport, + new NextcloudCallGate(), + BASE, + 'anna', + 'x', + ); + expect(b).toEqual({ ok: true, id: 'anna', displayName: null }); + }); + + it('ohne id: invalid-response', async () => { + const res = await getCurrentUser( + fake(() => reply(200, ocs({}))).transport, + new NextcloudCallGate(), + BASE, + 'anna', + 'x', + ); + expect(res).toMatchObject({ ok: false, kind: 'invalid-response' }); + }); +}); + +describe('revokeAppPassword', () => { + it('sendet DELETE apppassword mit dem App-Passwort', async () => { + const { transport, calls } = fake(() => reply(200, ocs([]))); + const res = await revokeAppPassword( + transport, + new NextcloudCallGate(), + BASE, + 'anna', + 'app-pw-123', + ); + expect(res).toEqual({ ok: true }); + expect(calls[0].method).toBe('DELETE'); + expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/apppassword'); + expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz'); + expect(calls[0].headersTimeoutMs).toBe(10_000); + }); + + it('ein toter Zugangsschluessel geht gar nicht erst raus', async () => { + const gate = new NextcloudCallGate(); + gate.markDead('key-1'); + const { transport, calls } = fake(() => reply(200, ocs([]))); + const res = await revokeAppPassword(transport, gate, BASE, 'anna', 'x', 'key-1'); + expect(res).toMatchObject({ ok: false, kind: 'credential-dead' }); + expect(calls).toHaveLength(0); + }); +}); + +describe('startLoginFlow', () => { + it('baut den Link aus Basis und Token neu und verwirft poll.endpoint und fremden Ursprung', async () => { + const { transport, calls } = fake(() => + reply(200, { + poll: { token: TOKEN128, endpoint: 'http://evil.example/login/v2/poll' }, + login: `http://evil.example/login/v2/flow/${TOKEN128}`, + }), + ); + const res = await startLoginFlow(transport, new NextcloudCallGate(), BASE); + expect(res).toEqual({ + ok: true, + loginUrl: `http://cloud.example/index.php/login/v2/flow/${TOKEN128}`, + pollToken: TOKEN128, + }); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toBe('http://cloud.example/index.php/login/v2'); + }); + + it('login ohne passendes Token: invalid-response', async () => { + const { transport } = fake(() => + reply(200, { + poll: { token: TOKEN128, endpoint: 'x' }, + login: 'http://cloud.example/anderswo', + }), + ); + expect(await startLoginFlow(transport, new NextcloudCallGate(), BASE)).toMatchObject({ + ok: false, + kind: 'invalid-response', + }); + }); + + it('ein unbrauchbares Abfrage-Token wird abgelehnt', async () => { + const { transport } = fake(() => + reply(200, { + poll: { token: 'kurz', endpoint: 'x' }, + login: `http://cloud.example/login/v2/flow/${TOKEN128}`, + }), + ); + expect(await startLoginFlow(transport, new NextcloudCallGate(), BASE)).toMatchObject({ + ok: false, + kind: 'invalid-response', + }); + }); +}); + +describe('pollLoginFlow', () => { + it('fragt nur {Basis}/index.php/login/v2/poll mit token=... ab', async () => { + const { transport, calls } = fake(() => reply(404)); + const res = await pollLoginFlow(transport, new NextcloudCallGate(), BASE, TOKEN128); + expect(res).toEqual({ ok: true, state: 'pending' }); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toBe('http://cloud.example/index.php/login/v2/poll'); + expect(calls[0].body).toBe(`token=${TOKEN128}`); + expect(calls[0].headers['content-type']).toBe('application/x-www-form-urlencoded'); + }); + + it('200: granted mit loginName und appPassword, server wird ignoriert; evil.example nie angefragt', async () => { + const { transport, calls } = fake(() => + reply(200, { server: 'http://evil.example', loginName: 'zoe', appPassword: 'x' }), + ); + const res = await pollLoginFlow(transport, new NextcloudCallGate(), BASE, TOKEN128); + expect(res).toEqual({ ok: true, state: 'granted', loginName: 'zoe', appPassword: 'x' }); + expect(calls.every((c) => !c.url.includes('evil.example'))).toBe(true); + }); + + it('200 ohne Zugangsdaten: invalid-response; 429: locked', async () => { + expect( + await pollLoginFlow( + fake(() => reply(200, {})).transport, + new NextcloudCallGate(), + BASE, + TOKEN128, + ), + ).toMatchObject({ ok: false, kind: 'invalid-response' }); + expect( + await pollLoginFlow( + fake(() => reply(429)).transport, + new NextcloudCallGate(), + BASE, + TOKEN128, + ), + ).toMatchObject({ ok: false, kind: 'locked' }); + }); +}); + +describe('Fehlerabbildung', () => { + const body = (kind: Parameters[0]['kind']) => + authFailureToException({ ok: false, kind, retryAfterSeconds: 42 }); + + it('antwortet nie mit 401 oder 403', () => { + for (const kind of [ + 'credentials', + 'app-password-given', + 'locked', + 'maintenance', + 'redirect', + 'timeout', + 'network', + 'tls', + 'invalid-response', + 'credential-dead', + 'upstream', + ] as const) { + const status = body(kind).getStatus(); + expect([401, 403], kind).not.toContain(status); + } + }); + + it('locked traegt retryAfterSeconds', () => { + expect(body('locked').getResponse()).toMatchObject({ + code: 'nextcloudLocked', + retryAfterSeconds: 42, + }); + expect(body('locked').getStatus()).toBe(503); + }); +}); + +describe('ocsRequest (allgemein)', () => { + it('liefert ocs.data bei 2xx', async () => { + const res = await ocsRequest( + fake(() => reply(200, ocs({ hallo: 'welt' }))).transport, + new NextcloudCallGate(), + BASE, + { method: 'GET', segments: ['cloud', 'capabilities'], authorization: 'Basic eDp5' }, + ); + expect(res).toEqual({ ok: true, status: 200, data: { hallo: 'welt' } }); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-auth-client.ts b/apps/api/src/nextcloud-files/nextcloud-auth-client.ts new file mode 100644 index 0000000..b880238 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-auth-client.ts @@ -0,0 +1,362 @@ +import type { HttpException } from '@nestjs/common'; +import { normalizeCloudUrl } from '../nextcloud-status/nextcloud-status-fetch'; +import type { NextcloudCallGate } from './nextcloud-call-gate'; +import { ncErrorDefault } from './nextcloud-files.types'; +import { + basicAuth, + type NcResult, + type NextcloudTransport, + ncRequest, + readCappedText, +} from './nextcloud-http'; + +/** + * Anmelde-Client des Moduls "Nextcloud-Dateien" (quick-261008-mzu): alles, was + * Zugangsdaten ausstellt, prueft oder widerruft. Jeder Aufruf geht durch + * `ncRequest` und damit durch die Aufrufsperre. + * + * Was hier gilt und warum: + * - Ein 401 auf `getapppassword` ist DOPPELDEUTIG: falsches Passwort ODER ein + * Konto mit Zwei-Faktor-Anmeldung (gemessen: Nextcloud lehnt solche Konten + * ab, bevor das Passwort geprueft wird). Der Aufrufer fragt deshalb mit + * `credentialsOrTwoFactor` nach und bietet die Browser-Anmeldung an. + * - Ein 429 wird NIE wiederholt. Die Aufrufsperre in `ncRequest` haelt den + * ganzen Ursprung an; hier wird es nur als `locked` gemeldet. + * - Mit einem App-Passwort laesst sich kein weiteres App-Passwort holen + * (403) — daran erkennt man einen Zugang, der nicht per Passwort geht. + * - `poll.endpoint` und der Ursprung von `login` aus der Antwort des + * Login Flow v2 werden VERWORFEN: Nextcloud baut sie aus dem Host-Header + * der Anfrage, ein falscher Wert waere eine Weiterleitung an einen + * fremden Host (SSRF). Abgefragt wird immer + * `{Basis}/index.php/login/v2/poll`; der Link fuer den Benutzer wird aus + * der Basis und dem Token der Antwort neu gebaut. + * - Passwoerter und App-Passwoerter stehen nur im `Authorization`-Wert eines + * einzelnen Aufrufs; nichts davon wird geloggt oder in Fehlern genannt. + */ + +/** Ergebnis ohne Erfolg; Art `credentials`/`app-password-given`/`locked` sind Anmelde-spezifisch. */ +export type AuthFailureKind = + | 'credentials' + | 'app-password-given' + | 'locked' + | 'maintenance' + | 'redirect' + | 'timeout' + | 'network' + | 'tls' + | 'invalid-response' + | 'credential-dead' + | 'upstream'; + +export interface AuthFailure { + ok: false; + kind: AuthFailureKind; + status?: number; + retryAfterSeconds?: number; +} + +const OCS_MAX_BYTES = 1024 * 1024; +const SMALL_MAX_BYTES = 256 * 1024; +const REVOKE_TIMEOUT_MS = 10_000; +const FLOW_TOKEN_RE = /^[A-Za-z0-9]{32,256}$/; +const FLOW_LOGIN_RE = /login\/v2\/flow\/([A-Za-z0-9]{32,256})$/; + +/** Ordnet ein Fehlergebnis der Transportschicht einer Anmelde-Fehlerart zu. */ +export function toAuthFailure(result: Extract): AuthFailure { + switch (result.kind) { + case 'paused': + return { ok: false, kind: 'locked', retryAfterSeconds: result.retryAfterSeconds }; + case 'http': + if (result.status === 429) { + return { ok: false, kind: 'locked', retryAfterSeconds: result.retryAfterSeconds }; + } + return { ok: false, kind: 'upstream', status: result.status }; + case 'redirect': + return { ok: false, kind: 'redirect' }; + case 'timeout': + return { ok: false, kind: 'timeout' }; + case 'tls': + return { ok: false, kind: 'tls' }; + case 'credential-dead': + return { ok: false, kind: 'credential-dead' }; + case 'too-large': + case 'invalid-response': + return { ok: false, kind: 'invalid-response' }; + default: + return { ok: false, kind: 'network' }; + } +} + +/** Wandelt einen Anmelde-Fehler in die Fehlerantwort der API (D-D, nie 401/403). */ +export function authFailureToException(failure: AuthFailure): HttpException { + switch (failure.kind) { + case 'locked': + return ncErrorDefault('nextcloudLocked', { + retryAfterSeconds: failure.retryAfterSeconds ?? 900, + }); + case 'maintenance': + return ncErrorDefault('nextcloudMaintenance'); + case 'redirect': + return ncErrorDefault('nextcloudRedirect'); + case 'timeout': + case 'network': + case 'tls': + return ncErrorDefault('nextcloudUnavailable'); + case 'credential-dead': + return ncErrorDefault('connectionExpired'); + case 'credentials': + return ncErrorDefault('credentialsOrTwoFactor'); + case 'app-password-given': + return ncErrorDefault('useBrowserLogin'); + default: + return ncErrorDefault('nextcloudError'); + } +} + +/** Fehler-Code zu einem Anmelde-Fehler (fuer `{ state: 'failed', code }`). */ +export function authFailureCode(failure: AuthFailure): string { + const body = authFailureToException(failure).getResponse() as { code: string }; + return body.code; +} + +interface OcsOk { + ok: true; + status: number; + data: unknown; +} + +interface OcsOptions { + method: string; + segments: readonly string[]; + authorization: string; + credentialKey?: string; + headersTimeoutMs?: number; + bodyTimeoutMs?: number; +} + +/** + * Allgemeiner OCS-Aufruf (Etappe 2 nutzt ihn fuer Freigaben). Liefert den + * entpackten `ocs.data`-Teil bei 2xx, sonst einen Fehler. 401 und 403 werden + * als `credentials` / `app-password-given` gemeldet; 503 als `maintenance`. + */ +export async function ocsRequest( + transport: NextcloudTransport, + gate: NextcloudCallGate, + baseUrl: string, + opts: OcsOptions, +): Promise { + const res = await ncRequest(transport, gate, { + baseUrl, + prefix: '/ocs/v2.php/', + segments: opts.segments, + method: opts.method, + authorization: opts.authorization, + credentialKey: opts.credentialKey, + ocs: true, + headersTimeoutMs: opts.headersTimeoutMs, + bodyTimeoutMs: opts.bodyTimeoutMs, + }); + if (!res.ok) return toAuthFailure(res); + + if (res.status === 401) { + await drain(res.body); + return { ok: false, kind: 'credentials', status: 401 }; + } + if (res.status === 403) { + await drain(res.body); + return { ok: false, kind: 'app-password-given', status: 403 }; + } + if (res.status === 503) { + await drain(res.body); + return { ok: false, kind: 'maintenance', status: 503 }; + } + if (res.status < 200 || res.status >= 300) { + await drain(res.body); + return { ok: false, kind: 'upstream', status: res.status }; + } + const text = await readCappedText(res.body, OCS_MAX_BYTES); + if (!text.ok) { + return text.kind === 'too-large' + ? { ok: false, kind: 'invalid-response' } + : { ok: false, kind: text.kind }; + } + try { + const parsed = JSON.parse(text.text) as { ocs?: { data?: unknown } }; + return { ok: true, status: res.status, data: parsed?.ocs?.data ?? null }; + } catch { + return { ok: false, kind: 'invalid-response' }; + } +} + +async function drain(body: Parameters[0]): Promise { + await readCappedText(body, SMALL_MAX_BYTES); +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +/** + * Loest mit Benutzername und echtem Passwort EINEN App-Passwort-Zugang aus. + * Das echte Passwort lebt nur in diesem Aufruf. + */ +export async function getAppPassword( + transport: NextcloudTransport, + gate: NextcloudCallGate, + baseUrl: string, + loginName: string, + password: string, +): Promise<{ ok: true; appPassword: string } | AuthFailure> { + const res = await ocsRequest(transport, gate, baseUrl, { + method: 'GET', + segments: ['core', 'getapppassword'], + authorization: basicAuth(loginName, password), + }); + if (!res.ok) return res; + const appPassword = asString((res.data as { apppassword?: unknown } | null)?.apppassword); + if (!appPassword) return { ok: false, kind: 'invalid-response' }; + return { ok: true, appPassword }; +} + +/** Wer ist das? `id` ist die Nextcloud-Kennung fuer die Dateipfade (nicht der Anmeldename). */ +export async function getCurrentUser( + transport: NextcloudTransport, + gate: NextcloudCallGate, + baseUrl: string, + loginName: string, + appPassword: string, +): Promise<{ ok: true; id: string; displayName: string | null } | AuthFailure> { + const res = await ocsRequest(transport, gate, baseUrl, { + method: 'GET', + segments: ['cloud', 'user'], + authorization: basicAuth(loginName, appPassword), + }); + if (!res.ok) return res; + const data = (res.data ?? {}) as Record; + const id = asString(data.id); + if (!id || id.length > 256) return { ok: false, kind: 'invalid-response' }; + const displayName = asString(data['display-name']) ?? asString(data.displayname); + return { ok: true, id, displayName: displayName ? displayName.slice(0, 256) : null }; +} + +/** Widerruft den App-Passwort-Zugang, mit dem der Aufruf selbst angemeldet ist. */ +export async function revokeAppPassword( + transport: NextcloudTransport, + gate: NextcloudCallGate, + baseUrl: string, + loginName: string, + appPassword: string, + credentialKey?: string, +): Promise<{ ok: true } | AuthFailure> { + const res = await ocsRequest(transport, gate, baseUrl, { + method: 'DELETE', + segments: ['core', 'apppassword'], + authorization: basicAuth(loginName, appPassword), + credentialKey, + headersTimeoutMs: REVOKE_TIMEOUT_MS, + bodyTimeoutMs: REVOKE_TIMEOUT_MS, + }); + return res.ok ? { ok: true } : res; +} + +/** Startet den Login Flow v2: Link fuer den Benutzer (neu gebaut) und Abfrage-Token (nur Server). */ +export async function startLoginFlow( + transport: NextcloudTransport, + gate: NextcloudCallGate, + baseUrl: string, +): Promise<{ ok: true; loginUrl: string; pollToken: string } | AuthFailure> { + const base = normalizeCloudUrl(baseUrl); + if (base === null) return { ok: false, kind: 'invalid-response' }; + const res = await ncRequest(transport, gate, { + baseUrl: base, + prefix: '/index.php/login/v2', + method: 'POST', + headers: { accept: 'application/json' }, + }); + if (!res.ok) return toAuthFailure(res); + if (res.status < 200 || res.status >= 300) { + await drain(res.body); + return res.status === 503 + ? { ok: false, kind: 'maintenance', status: 503 } + : { ok: false, kind: 'upstream', status: res.status }; + } + const text = await readCappedText(res.body, SMALL_MAX_BYTES); + if (!text.ok) { + return text.kind === 'too-large' + ? { ok: false, kind: 'invalid-response' } + : { ok: false, kind: text.kind }; + } + try { + const parsed = JSON.parse(text.text) as { + poll?: { token?: unknown }; + login?: unknown; + }; + const pollToken = parsed?.poll?.token; + const login = parsed?.login; + if (typeof pollToken !== 'string' || !FLOW_TOKEN_RE.test(pollToken)) { + return { ok: false, kind: 'invalid-response' }; + } + if (typeof login !== 'string') return { ok: false, kind: 'invalid-response' }; + const match = FLOW_LOGIN_RE.exec(login); + if (!match) return { ok: false, kind: 'invalid-response' }; + return { + ok: true, + loginUrl: `${base}/index.php/login/v2/flow/${match[1]}`, + pollToken, + }; + } catch { + return { ok: false, kind: 'invalid-response' }; + } +} + +export type PollResult = + | { ok: true; state: 'pending' } + | { ok: true; state: 'granted'; loginName: string; appPassword: string }; + +/** + * Fragt die Browser-Anmeldung ab — IMMER `{Basis}/index.php/login/v2/poll`, nie + * die `poll.endpoint` aus der Antwort. 404 heisst: noch nicht bestaetigt. + */ +export async function pollLoginFlow( + transport: NextcloudTransport, + gate: NextcloudCallGate, + baseUrl: string, + pollToken: string, +): Promise { + const res = await ncRequest(transport, gate, { + baseUrl, + prefix: '/index.php/login/v2/poll', + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: `token=${encodeURIComponent(pollToken)}`, + }); + if (!res.ok) return toAuthFailure(res); + if (res.status === 404) { + await drain(res.body); + return { ok: true, state: 'pending' }; + } + if (res.status < 200 || res.status >= 300) { + await drain(res.body); + return res.status === 503 + ? { ok: false, kind: 'maintenance', status: 503 } + : { ok: false, kind: 'upstream', status: res.status }; + } + const text = await readCappedText(res.body, SMALL_MAX_BYTES); + if (!text.ok) { + return text.kind === 'too-large' + ? { ok: false, kind: 'invalid-response' } + : { ok: false, kind: text.kind }; + } + try { + const parsed = JSON.parse(text.text) as { loginName?: unknown; appPassword?: unknown }; + const loginName = asString(parsed?.loginName); + const appPassword = asString(parsed?.appPassword); + if (!loginName || !appPassword || loginName.length > 256 || appPassword.length > 512) { + return { ok: false, kind: 'invalid-response' }; + } + // `server` aus der Antwort wird ignoriert (siehe Kopfkommentar). + return { ok: true, state: 'granted', loginName, appPassword }; + } catch { + return { ok: false, kind: 'invalid-response' }; + } +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts new file mode 100644 index 0000000..50dba1b --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts @@ -0,0 +1,719 @@ +import { createHash } from 'node:crypto'; +import { Readable } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((db: any, tenantId: string, userId?: string) => db.__bound(tenantId, userId)), +})); + +import { NextcloudCallGate } from './nextcloud-call-gate'; +import { credentialKeyOf, NextcloudFilesAccountService } from './nextcloud-files-account.service'; +import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http'; +import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; + +const BASE = 'http://cloud.example'; +const TOKEN128 = 'A1b2C3d4'.repeat(16); + +// Wertetabelle der Literale: anna:geheim, anna:app-pw-123, anna:old-pw +const AUTH_PASSWORD = 'Basic YW5uYTpnZWhlaW0='; +const AUTH_APP = 'Basic YW5uYTphcHAtcHctMTIz'; +const AUTH_OLD = 'Basic YW5uYTpvbGQtcHc='; +// anna@example.com:app-pw-123 +const AUTH_EMAIL_APP = 'Basic YW5uYUBleGFtcGxlLmNvbTphcHAtcHctMTIz'; + +function reply( + statusCode: number, + body: unknown = '', + headers: Record = {}, +): NcTransportResponse { + const text = typeof body === 'string' ? body : JSON.stringify(body); + return { statusCode, headers, body: Readable.from(text === '' ? [] : [Buffer.from(text)]) }; +} +const ocs = (data: unknown) => ({ ocs: { meta: { status: 'ok' }, data } }); + +interface Row { + tenantId: string; + userId: string; + baseUrl: string; + ncUserId: string; + ncLoginName: string | null; + ncDisplayName: string | null; + encryptedAppPassword: string; + status: 'ACTIVE' | 'EXPIRED'; + connectedVia: 'PASSWORD' | 'LOGIN_FLOW'; + createdAt: Date; + updatedAt: Date; +} + +function makeRow(over: Partial = {}): Row { + return { + tenantId: 't1', + userId: 'u1', + baseUrl: BASE, + ncUserId: 'anna', + ncLoginName: 'anna', + ncDisplayName: 'Anna Müller', + encryptedAppPassword: 'enc(app-pw-123)', + status: 'ACTIVE', + connectedVia: 'PASSWORD', + createdAt: new Date('2026-10-01T10:00:00Z'), + updatedAt: new Date('2026-10-02T10:00:00Z'), + ...over, + }; +} + +interface Setup { + rows?: Row[]; + base?: string | null; + upsertFails?: boolean; + decryptFails?: boolean; + handler?: (req: NcTransportRequest) => NcTransportResponse; +} + +function setup(opts: Setup = {}) { + const rows: Row[] = opts.rows ? [...opts.rows] : []; + const bound: { tenantId: string; userId?: string }[] = []; + const dbCalls: { op: string; args: any }[] = []; + const matches = (r: Row, where: any) => + (where.tenantId === undefined || r.tenantId === where.tenantId) && + (where.userId === undefined || r.userId === where.userId) && + (where.status === undefined || r.status === where.status); + const db = { + __bound: (tenantId: string, userId?: string) => { + bound.push({ tenantId, userId }); + return { + nextcloudFilesAccount: { + findFirst: vi.fn(async (args: any) => { + dbCalls.push({ op: 'findFirst', args }); + return rows.find((r) => matches(r, args.where)) ?? null; + }), + upsert: vi.fn(async (args: any) => { + dbCalls.push({ op: 'upsert', args }); + if (opts.upsertFails) throw new Error('db down'); + const key = args.where.tenantId_userId; + const i = rows.findIndex((r) => r.tenantId === key.tenantId && r.userId === key.userId); + if (i >= 0) rows[i] = { ...rows[i], ...args.update, updatedAt: new Date() }; + else rows.push({ ...makeRow(), ...args.create, updatedAt: new Date() }); + return rows[i >= 0 ? i : rows.length - 1]; + }), + deleteMany: vi.fn(async (args: any) => { + dbCalls.push({ op: 'deleteMany', args }); + for (let i = rows.length - 1; i >= 0; i--) + if (matches(rows[i], args.where)) rows.splice(i, 1); + return { count: 1 }; + }), + updateMany: vi.fn(async (args: any) => { + dbCalls.push({ op: 'updateMany', args }); + for (const r of rows) if (matches(r, args.where)) Object.assign(r, args.data); + return { count: 1 }; + }), + }, + }; + }, + }; + const base = opts.base === undefined ? BASE : opts.base; + const listeners: ((t: string) => void)[] = []; + const settings = { + getBaseUrl: vi.fn(async () => base), + getStatus: vi.fn(async () => ({ + configured: base !== null, + serverUrl: base, + host: base ? new URL(base).host : null, + account: null, + })), + onAddressChange: (l: (t: string) => void) => listeners.push(l), + }; + const crypto = { + encrypt: vi.fn((p: string) => `enc(${p})`), + decrypt: vi.fn((e: string) => { + if (opts.decryptFails) throw new Error('bad cipher'); + const m = /^enc\((.*)\)$/.exec(e); + if (!m) throw new Error('bad cipher'); + return m[1]; + }), + }; + const calls: NcTransportRequest[] = []; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + return (opts.handler ?? (() => reply(500)))(req); + }; + const gate = new NextcloudCallGate(); + const guard = new NextcloudLoginGuard(); + const flows = new LoginFlowStore(); + const service = new NextcloudFilesAccountService( + db as any, + crypto as any, + settings as any, + guard, + flows, + gate, + transport, + ); + return { service, rows, bound, dbCalls, settings, crypto, calls, gate, guard, flows, listeners }; +} + +/** Typische Nextcloud-Antworten fuer anna. */ +function happy(req: NcTransportRequest): NcTransportResponse { + if (req.url.endsWith('/core/getapppassword')) + return reply(200, ocs({ apppassword: 'app-pw-123' })); + if (req.url.endsWith('/cloud/user')) + return reply(200, ocs({ id: 'anna', 'display-name': 'Anna Müller' })); + if (req.url.endsWith('/core/apppassword') && req.method === 'DELETE') return reply(200, ocs([])); + return reply(500); +} + +const errOf = async (p: Promise) => { + try { + await p; + } catch (e) { + return { status: (e as any).getStatus?.() as number, body: (e as any).getResponse?.() as any }; + } + return null; +}; + +describe('Verbinden mit Passwort', () => { + it('zwei Aufrufe (getapppassword, cloud/user), nur das verschluesselte App-Passwort wird gespeichert', async () => { + const s = setup({ handler: happy }); + const result = await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'); + + expect(s.calls).toHaveLength(2); + expect(s.calls[0].method).toBe('GET'); + expect(s.calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/getapppassword'); + expect(s.calls[0].headers.authorization).toBe(AUTH_PASSWORD); + expect(s.calls[1].url).toBe('http://cloud.example/ocs/v2.php/cloud/user'); + expect(s.calls[1].headers.authorization).toBe(AUTH_APP); + + const upsert = s.dbCalls.find((c) => c.op === 'upsert')!; + expect(upsert.args.create).toMatchObject({ + tenantId: 't1', + userId: 'u1', + baseUrl: BASE, + ncUserId: 'anna', + ncLoginName: 'anna', + ncDisplayName: 'Anna Müller', + encryptedAppPassword: 'enc(app-pw-123)', + connectedVia: 'PASSWORD', + status: 'ACTIVE', + }); + + // Weder Antwort noch irgendein Aufrufargument ausser der Upsert-Nutzlast tragen ein Geheimnis. + const everythingElse = JSON.stringify([result, s.dbCalls.filter((c) => c.op !== 'upsert')]); + expect(everythingElse).not.toContain('geheim'); + expect(everythingElse).not.toContain('app-pw-123'); + expect(JSON.stringify(upsert.args)).not.toContain('geheim'); + expect(s.crypto.encrypt).toHaveBeenCalledTimes(1); + expect(s.crypto.encrypt).toHaveBeenCalledWith('app-pw-123'); + expect(result.account).toMatchObject({ connected: true, ncUserId: 'anna', status: 'ACTIVE' }); + }); + + it('Nextcloud 401: 422 credentialsOrTwoFactor und ein gezaehlter Fehlversuch; der 4. Versuch ist 429 ohne Aufruf', async () => { + const s = setup({ handler: () => reply(401) }); + for (let i = 0; i < 3; i++) { + const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'zoe', 'x')); + expect(err?.status).toBe(422); + expect(err?.body.code).toBe('credentialsOrTwoFactor'); + } + expect(s.calls).toHaveLength(3); + const blocked = await errOf(s.service.connectWithPassword('t1', 'u1', 'zoe', 'x')); + expect(blocked?.status).toBe(429); + expect(blocked?.body.code).toBe('tooManyAttempts'); + expect(blocked?.body.retryAfterSeconds).toBeGreaterThan(0); + expect(s.calls).toHaveLength(3); + }); + + it('Nextcloud 403 auf getapppassword: 422 useBrowserLogin, kein Fehlversuch', async () => { + const s = setup({ handler: () => reply(403) }); + const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'x')); + expect(err?.status).toBe(422); + expect(err?.body.code).toBe('useBrowserLogin'); + expect(() => s.guard.checkPasswordAttempt('u1', BASE)).not.toThrow(); + }); + + it('Nextcloud 429: 503 nextcloudLocked; der naechste Versuch (anderer Benutzer) erreicht Nextcloud nicht mehr', async () => { + const s = setup({ handler: () => reply(429, '', { 'retry-after': '900' }) }); + const first = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'x')); + expect(first?.status).toBe(503); + expect(first?.body.code).toBe('nextcloudLocked'); + expect(first?.body.retryAfterSeconds).toBe(900); + const second = await errOf(s.service.connectWithPassword('t1', 'u2', 'bert', 'y')); + expect(second?.status).toBe(503); + expect(second?.body.code).toBe('nextcloudLocked'); + expect(s.calls).toHaveLength(1); + }); + + it('nicht eingerichtet: 409 notConfigured ohne Aufruf', async () => { + const s = setup({ base: null, handler: happy }); + const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim')); + expect(err?.status).toBe(409); + expect(err?.body.code).toBe('notConfigured'); + expect(s.calls).toHaveLength(0); + }); + + it('keine Antwort mit 401 oder 403 fuer Nextcloud-Fehler', async () => { + for (const status of [404, 500, 503]) { + const s = setup({ handler: () => reply(status) }); + const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'x')); + expect([401, 403]).not.toContain(err?.status); + } + }); +}); + +describe('App-Passwort-Hygiene (D-P)', () => { + it('cloud/user scheitert nach erfolgreichem getapppassword: genau ein Widerruf, kein Upsert', async () => { + const s = setup({ + handler: (req) => { + if (req.url.endsWith('/core/getapppassword')) + return reply(200, ocs({ apppassword: 'app-pw-123' })); + if (req.url.endsWith('/cloud/user')) return reply(500); + if (req.method === 'DELETE') return reply(200, ocs([])); + return reply(500); + }, + }); + const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim')); + expect(err).not.toBeNull(); + const deletes = s.calls.filter((c) => c.method === 'DELETE'); + expect(deletes).toHaveLength(1); + expect(deletes[0].url).toBe('http://cloud.example/ocs/v2.php/core/apppassword'); + expect(deletes[0].headers.authorization).toBe(AUTH_APP); + expect(s.dbCalls.some((c) => c.op === 'upsert')).toBe(false); + }); + + it('Upsert wirft: derselbe Widerruf, Fehler wird weitergegeben', async () => { + const s = setup({ handler: happy, upsertFails: true }); + const err = await errOf(s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim')); + expect(err).not.toBeNull(); + const deletes = s.calls.filter((c) => c.method === 'DELETE'); + expect(deletes).toHaveLength(1); + expect(deletes[0].headers.authorization).toBe(AUTH_APP); + }); + + it('erneutes Verbinden: das alte App-Passwort derselben Adresse wird VOR dem Upsert widerrufen', async () => { + const order: string[] = []; + const s = setup({ + rows: [makeRow({ encryptedAppPassword: 'enc(old-pw)' })], + handler: (req) => { + if (req.method === 'DELETE') order.push(`delete:${req.headers.authorization}`); + return happy(req); + }, + }); + s.crypto.encrypt.mockImplementation((p: string) => { + order.push('encrypt'); + return `enc(${p})`; + }); + await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'); + expect(order[0]).toBe(`delete:${AUTH_OLD}`); + expect(order.indexOf('encrypt')).toBeGreaterThan(0); + expect(s.rows[0].encryptedAppPassword).toBe('enc(app-pw-123)'); + // Genau ein Widerruf (das alte), das frische bleibt bestehen. + expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(1); + }); + + it('Zeile fuer eine andere Adresse: kein Widerruf an irgendeinen Host, Zeile wird ueberschrieben', async () => { + const s = setup({ + rows: [makeRow({ baseUrl: 'http://alt.example', encryptedAppPassword: 'enc(old-pw)' })], + handler: happy, + }); + await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'); + expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(0); + expect(s.calls.every((c) => c.url.startsWith('http://cloud.example/'))).toBe(true); + expect(s.rows[0].baseUrl).toBe(BASE); + expect(s.rows[0].encryptedAppPassword).toBe('enc(app-pw-123)'); + }); +}); + +describe('Browser-Anmeldung (Login Flow v2)', () => { + const flowHandler = + (extra?: (req: NcTransportRequest) => NcTransportResponse | undefined) => + (req: NcTransportRequest) => { + const custom = extra?.(req); + if (custom) return custom; + if (req.url === 'http://cloud.example/index.php/login/v2') { + return reply(200, { + poll: { token: TOKEN128, endpoint: 'http://evil.example/poll' }, + login: `http://evil.example/login/v2/flow/${TOKEN128}`, + }); + } + if (req.url === 'http://cloud.example/index.php/login/v2/poll') return reply(404); + return happy(req); + }; + + it('startFlow liefert flowId, Link und Ablauf, aber nie das Abfrage-Token', async () => { + const s = setup({ handler: flowHandler() }); + const res = await s.service.startFlow('t1', 'u1'); + expect(res.flowId).toMatch(/^[0-9a-f-]{36}$/); + expect(res.loginUrl).toBe(`http://cloud.example/index.php/login/v2/flow/${TOKEN128}`); + expect(new Date(res.expiresAt).getTime()).toBeGreaterThan(Date.now()); + expect(JSON.stringify(res)).not.toContain('poll'); + // Das Abfrage-Token taucht im Link nicht als eigenes Feld auf; es steckt nur dort, wo es der Benutzer braucht. + expect(Object.keys(res).sort()).toEqual(['expiresAt', 'flowId', 'loginUrl']); + }); + + it('pollFlow: pending, dann granted -> verbunden mit LOGIN_FLOW, Ablauf entfernt', async () => { + let granted = false; + const s = setup({ + handler: flowHandler((req) => { + if (req.url.endsWith('/login/v2/poll')) { + return granted + ? reply(200, { + server: 'http://evil.example', + loginName: 'anna', + appPassword: 'app-pw-123', + }) + : reply(404); + } + return undefined; + }), + }); + const { flowId } = await s.service.startFlow('t1', 'u1'); + expect(await s.service.pollFlow('t1', 'u1', flowId)).toEqual({ state: 'pending' }); + + granted = true; + const entry = s.flows.get(flowId, 't1', 'u1')!; + entry.lastPollAt = Number.NEGATIVE_INFINITY; + expect(await s.service.pollFlow('t1', 'u1', flowId)).toEqual({ state: 'connected' }); + const upsert = s.dbCalls.find((c) => c.op === 'upsert')!; + expect(upsert.args.create).toMatchObject({ + connectedVia: 'LOGIN_FLOW', + encryptedAppPassword: 'enc(app-pw-123)', + ncUserId: 'anna', + }); + expect(s.flows.get(flowId, 't1', 'u1')).toBeUndefined(); + const polls = s.calls.filter((c) => c.url.endsWith('/login/v2/poll')); + expect(polls.every((c) => c.url === 'http://cloud.example/index.php/login/v2/poll')).toBe(true); + expect(s.calls.every((c) => !c.url.includes('evil.example'))).toBe(true); + }); + + it('schnelle Browser-Abfragen (unter 1,5 s) erreichen Nextcloud nicht', async () => { + const s = setup({ handler: flowHandler() }); + const { flowId } = await s.service.startFlow('t1', 'u1'); + await s.service.pollFlow('t1', 'u1', flowId); + const before = s.calls.length; + expect(await s.service.pollFlow('t1', 'u1', flowId)).toEqual({ state: 'pending' }); + expect(s.calls.length).toBe(before); + }); + + it('granted, aber cloud/user scheitert: Widerruf des erteilten Passworts und failed', async () => { + const s = setup({ + handler: flowHandler((req) => { + if (req.url.endsWith('/login/v2/poll')) { + return reply(200, { server: 'x', loginName: 'anna', appPassword: 'app-pw-123' }); + } + if (req.url.endsWith('/cloud/user')) return reply(500); + return undefined; + }), + }); + const { flowId } = await s.service.startFlow('t1', 'u1'); + const res = await s.service.pollFlow('t1', 'u1', flowId); + expect(res.state).toBe('failed'); + const deletes = s.calls.filter((c) => c.method === 'DELETE'); + expect(deletes).toHaveLength(1); + expect(deletes[0].headers.authorization).toBe(AUTH_APP); + expect(s.dbCalls.some((c) => c.op === 'upsert')).toBe(false); + }); + + it('abgebrochener Ablauf, dessen Abfrage noch ein granted bringt: das Passwort wird widerrufen', async () => { + let s!: ReturnType; + let flowId = ''; + s = setup({ + handler: flowHandler((req) => { + if (req.url.endsWith('/login/v2/poll')) { + // Waehrend die Abfrage unterwegs ist, bricht der Benutzer ab. + void s.service.cancelFlow('t1', 'u1', flowId); + return reply(200, { server: 'x', loginName: 'anna', appPassword: 'app-pw-123' }); + } + return undefined; + }), + }); + flowId = (await s.service.startFlow('t1', 'u1')).flowId; + const res = await s.service.pollFlow('t1', 'u1', flowId); + expect(res.state).toBe('failed'); + expect(s.calls.filter((c) => c.method === 'DELETE')).toHaveLength(1); + expect(s.dbCalls.some((c) => c.op === 'upsert')).toBe(false); + }); + + it('fremde Kennung: 404; abgelaufene: 410 flowExpired; Abbrechen entfernt', async () => { + const s = setup({ handler: flowHandler() }); + const { flowId } = await s.service.startFlow('t1', 'u1'); + const foreign = await errOf(s.service.pollFlow('t1', 'u2', flowId)); + expect(foreign?.status).toBe(404); + const foreignCancel = await errOf(s.service.cancelFlow('t1', 'u2', flowId)); + expect(foreignCancel?.status).toBe(404); + expect(s.flows.get(flowId, 't1', 'u1')).toBeDefined(); + + s.flows.now = () => Date.now() + 21 * 60 * 1000; + const expired = await errOf(s.service.pollFlow('t1', 'u1', flowId)); + expect(expired?.status).toBe(410); + expect(expired?.body.code).toBe('flowExpired'); + }); + + it('cancelFlow entfernt den eigenen Ablauf', async () => { + const s = setup({ handler: flowHandler() }); + const { flowId } = await s.service.startFlow('t1', 'u1'); + expect(await s.service.cancelFlow('t1', 'u1', flowId)).toEqual({ cancelled: true }); + expect((await errOf(s.service.pollFlow('t1', 'u1', flowId)))?.status).toBe(404); + }); + + it('Adresswechsel: der Zuhoerer leert die Ablaeufe der Organisation', async () => { + const s = setup({ handler: flowHandler() }); + const { flowId } = await s.service.startFlow('t1', 'u1'); + expect(s.listeners).toHaveLength(1); + s.listeners[0]('t1'); + expect(s.flows.get(flowId, 't1', 'u1')).toBeUndefined(); + }); + + it('Adresse seit dem Start geaendert: 410 flowExpired ohne Nextcloud-Aufruf', async () => { + const s = setup({ handler: flowHandler() }); + const { flowId } = await s.service.startFlow('t1', 'u1'); + const before = s.calls.length; + s.settings.getBaseUrl.mockResolvedValue('http://neu.example'); + const err = await errOf(s.service.pollFlow('t1', 'u1', flowId)); + expect(err?.status).toBe(410); + expect(s.calls.length).toBe(before); + }); +}); + +describe('Trennen', () => { + it('aktives Konto: Widerruf mit dem gespeicherten App-Passwort an die Adresse des Kontos, dann Zeile loeschen', async () => { + const s = setup({ rows: [makeRow()], handler: happy }); + await s.service.disconnect('t1', 'u1'); + expect(s.calls).toHaveLength(1); + expect(s.calls[0].method).toBe('DELETE'); + expect(s.calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/apppassword'); + expect(s.calls[0].headers.authorization).toBe(AUTH_APP); + expect(s.dbCalls.find((c) => c.op === 'deleteMany')?.args).toEqual({ + where: { tenantId: 't1', userId: 'u1' }, + }); + expect(s.rows).toHaveLength(0); + }); + + it('Widerruf scheitert (500): die Zeile wird trotzdem geloescht', async () => { + const s = setup({ rows: [makeRow()], handler: () => reply(500) }); + await s.service.disconnect('t1', 'u1'); + expect(s.rows).toHaveLength(0); + }); + + it('Widerruf laeuft in den Zeitablauf: die Zeile wird trotzdem geloescht', async () => { + const s = setup({ + rows: [makeRow()], + handler: () => { + throw Object.assign(new Error('t'), { code: 'UND_ERR_HEADERS_TIMEOUT' }); + }, + }); + await s.service.disconnect('t1', 'u1'); + expect(s.rows).toHaveLength(0); + }); + + it('andere Adresse als die aktuelle: gar kein Aufruf, Zeile geloescht', async () => { + const s = setup({ rows: [makeRow({ baseUrl: 'http://alt.example' })], handler: happy }); + await s.service.disconnect('t1', 'u1'); + expect(s.calls).toHaveLength(0); + expect(s.rows).toHaveLength(0); + }); + + it('abgelaufene Zeile: kein Widerruf (der Zugang ist schon ungueltig)', async () => { + const s = setup({ rows: [makeRow({ status: 'EXPIRED' })], handler: happy }); + await s.service.disconnect('t1', 'u1'); + expect(s.calls).toHaveLength(0); + expect(s.rows).toHaveLength(0); + }); + + it('Entschluesselung scheitert: kein Aufruf, Zeile geloescht', async () => { + const s = setup({ rows: [makeRow()], handler: happy, decryptFails: true }); + await s.service.disconnect('t1', 'u1'); + expect(s.calls).toHaveLength(0); + expect(s.rows).toHaveLength(0); + }); + + it('keine Zeile: 409 notConnected', async () => { + const s = setup({ handler: happy }); + const err = await errOf(s.service.disconnect('t1', 'u1')); + expect(err?.status).toBe(409); + expect(err?.body.code).toBe('notConnected'); + }); +}); + +describe('getSession', () => { + it('Benutzer B ohne Zeile: 409 notConnected, obwohl A verbunden ist; Zugriff nur mit B gebunden', async () => { + const s = setup({ rows: [makeRow({ userId: 'uA' })], handler: happy }); + const err = await errOf(s.service.getSession('t1', 'uB')); + expect(err?.status).toBe(409); + expect(err?.body.code).toBe('notConnected'); + expect(s.bound.every((b) => b.tenantId === 't1' && b.userId === 'uB')).toBe(true); + const find = s.dbCalls.find((c) => c.op === 'findFirst')!; + expect(find.args.where).toEqual({ tenantId: 't1', userId: 'uB' }); + }); + + it('abgelaufene Zeile oder andere Adresse: 409 connectionExpired', async () => { + const expired = setup({ rows: [makeRow({ status: 'EXPIRED' })] }); + expect((await errOf(expired.service.getSession('t1', 'u1')))?.body.code).toBe( + 'connectionExpired', + ); + const moved = setup({ rows: [makeRow({ baseUrl: 'http://alt.example' })] }); + const err = await errOf(moved.service.getSession('t1', 'u1')); + expect(err?.status).toBe(409); + expect(err?.body.code).toBe('connectionExpired'); + }); + + it('bereits toter Zugangsschluessel: Konto wird abgelaufen gesetzt, kein Transportaufruf', async () => { + const s = setup({ rows: [makeRow()], handler: happy }); + s.gate.markDead(credentialKeyOf('enc(app-pw-123)')); + const err = await errOf(s.service.getSession('t1', 'u1')); + expect(err?.status).toBe(409); + expect(err?.body.code).toBe('connectionExpired'); + expect(s.rows[0].status).toBe('EXPIRED'); + expect(s.calls).toHaveLength(0); + expect(s.dbCalls.find((c) => c.op === 'updateMany')?.args.where).toMatchObject({ + tenantId: 't1', + userId: 'u1', + }); + }); + + it('Entschluesselung scheitert: 500 accountBroken', async () => { + const s = setup({ rows: [makeRow()], decryptFails: true }); + const err = await errOf(s.service.getSession('t1', 'u1')); + expect(err?.status).toBe(500); + expect(err?.body.code).toBe('accountBroken'); + }); + + it('Erfolg: Sitzung mit Zugangsschluessel = erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert', async () => { + const s = setup({ rows: [makeRow()] }); + const session = await s.service.getSession('t1', 'u1'); + expect(session).toEqual({ + baseUrl: BASE, + ncUserId: 'anna', + authorization: AUTH_APP, + credentialKey: createHash('sha256').update('enc(app-pw-123)').digest('hex').slice(0, 16), + }); + }); + + it('nicht eingerichtet: 409 notConfigured', async () => { + const s = setup({ base: null }); + expect((await errOf(s.service.getSession('t1', 'u1')))?.body.code).toBe('notConfigured'); + }); +}); + +describe('getStatus', () => { + it('ohne Zeile: account null; nicht eingerichtet: account null', async () => { + expect((await setup().service.getStatus('t1', 'u1')).account).toBeNull(); + expect((await setup({ base: null }).service.getStatus('t1', 'u1')).account).toBeNull(); + }); + + it('aktiv: verbunden mit Namen, Weg und Zeitpunkt, ohne Geheimnis', async () => { + const view = await setup({ rows: [makeRow()] }).service.getStatus('t1', 'u1'); + expect(view.account).toEqual({ + connected: true, + expired: false, + status: 'ACTIVE', + ncUserId: 'anna', + displayName: 'Anna Müller', + connectedVia: 'PASSWORD', + connectedAt: '2026-10-02T10:00:00.000Z', + }); + expect(JSON.stringify(view)).not.toContain('app-pw-123'); + expect(JSON.stringify(view)).not.toContain('enc('); + }); + + it('EXPIRED oder andere Adresse: status EXPIRED', async () => { + const a = await setup({ rows: [makeRow({ status: 'EXPIRED' })] }).service.getStatus('t1', 'u1'); + expect(a.account).toMatchObject({ connected: false, expired: true, status: 'EXPIRED' }); + const b = await setup({ rows: [makeRow({ baseUrl: 'http://alt.example' })] }).service.getStatus( + 't1', + 'u1', + ); + expect(b.account).toMatchObject({ connected: false, expired: true, status: 'EXPIRED' }); + }); +}); + +describe('Zeilenzugriff', () => { + let s: ReturnType; + beforeEach(() => { + s = setup({ rows: [makeRow()], handler: happy }); + }); + + it('jeder Kontozugriff nutzt forTenant mit Mandant UND Benutzer des Aufrufers', async () => { + await s.service.getStatus('t1', 'u1'); + await s.service.getSession('t1', 'u1'); + await s.service.connectWithPassword('t1', 'u1', 'anna', 'geheim'); + await s.service.markExpired('t1', 'u1'); + await s.service.disconnect('t1', 'u1').catch(() => undefined); + expect(s.bound.length).toBeGreaterThan(0); + expect(s.bound.every((b) => b.tenantId === 't1' && b.userId === 'u1')).toBe(true); + for (const call of s.dbCalls) { + const where = call.args.where?.tenantId_userId ?? call.args.where; + expect(where, call.op).toMatchObject({ tenantId: 't1', userId: 'u1' }); + } + }); +}); + +describe('Anmeldename = Basic-Benutzer (E-Mail-Anmeldung, gemessen gegen Nextcloud 34)', () => { + const emailHandler = (req: NcTransportRequest) => happy(req); + + it('Anmeldung mit E-Mail: cloud/user, Speichern und Sitzung nutzen die E-Mail als Basic-Benutzer, die Kennung bleibt fuer Pfade', async () => { + const s = setup({ handler: emailHandler }); + await s.service.connectWithPassword('t1', 'u1', 'anna@example.com', 'geheim'); + expect(s.calls[1].url).toBe('http://cloud.example/ocs/v2.php/cloud/user'); + expect(s.calls[1].headers.authorization).toBe(AUTH_EMAIL_APP); + const upsert = s.dbCalls.find((c) => c.op === 'upsert'); + expect(upsert?.args.create).toMatchObject({ + ncUserId: 'anna', + ncLoginName: 'anna@example.com', + }); + const session = await s.service.getSession('t1', 'u1'); + expect(session.ncUserId).toBe('anna'); + expect(session.authorization).toBe(AUTH_EMAIL_APP); + }); + + it('Trennen widerruft mit dem Anmeldenamen der Ausstellung', async () => { + const s = setup({ rows: [makeRow({ ncLoginName: 'anna@example.com' })], handler: happy }); + await s.service.disconnect('t1', 'u1'); + expect(s.calls).toHaveLength(1); + expect(s.calls[0].headers.authorization).toBe(AUTH_EMAIL_APP); + }); + + it('Konto aus der Zeit vor der Spalte (ncLoginName null): Kennung ist der Basic-Benutzer', async () => { + const s = setup({ rows: [makeRow({ ncLoginName: null })] }); + expect((await s.service.getSession('t1', 'u1')).authorization).toBe(AUTH_APP); + }); + + it('Widerruf eines nicht gespeicherten Zugangs nutzt den eingegebenen Anmeldenamen', async () => { + const s = setup({ + upsertFails: true, + handler: (req) => + req.url.endsWith('/cloud/user') + ? reply(200, ocs({ id: 'anna', 'display-name': 'Anna' })) + : happy(req), + }); + await errOf(s.service.connectWithPassword('t1', 'u1', 'anna@example.com', 'geheim')); + const deletes = s.calls.filter((c) => c.method === 'DELETE'); + expect(deletes).toHaveLength(1); + expect(deletes[0].headers.authorization).toBe(AUTH_EMAIL_APP); + }); + + it('Browser-Anmeldung: loginName der Nextcloud wird als Basic-Benutzer gespeichert', async () => { + const s = setup({ + handler: (req) => { + if (req.url === 'http://cloud.example/index.php/login/v2') { + return reply(200, { + poll: { token: TOKEN128, endpoint: 'x' }, + login: `http://cloud.example/login/v2/flow/${TOKEN128}`, + }); + } + if (req.url.endsWith('/login/v2/poll')) { + return reply(200, { + server: 'x', + loginName: 'anna@example.com', + appPassword: 'app-pw-123', + }); + } + return happy(req); + }, + }); + const { flowId } = await s.service.startFlow('t1', 'u1'); + expect(await s.service.pollFlow('t1', 'u1', flowId)).toEqual({ state: 'connected' }); + expect(s.dbCalls.find((c) => c.op === 'upsert')?.args.create).toMatchObject({ + ncUserId: 'anna', + ncLoginName: 'anna@example.com', + connectedVia: 'LOGIN_FLOW', + }); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts new file mode 100644 index 0000000..f58f854 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts @@ -0,0 +1,467 @@ +import { createHash } from 'node:crypto'; +import { HttpException, Inject, Injectable, Logger } from '@nestjs/common'; +import { CryptoService } from '../crypto/crypto.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { + type AuthFailure, + authFailureCode, + authFailureToException, + getAppPassword, + getCurrentUser, + pollLoginFlow, + revokeAppPassword, + startLoginFlow, +} from './nextcloud-auth-client'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import { + type NcSession, + type NextcloudFilesAccountView, + type NextcloudFilesStatusView, + ncErrorDefault, +} from './nextcloud-files.types'; +import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; +import { basicAuth, NEXTCLOUD_TRANSPORT, type NextcloudTransport } from './nextcloud-http'; +import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; + +type ConnectMethod = 'PASSWORD' | 'LOGIN_FLOW'; + +interface AccountRow { + baseUrl: string; + ncUserId: string; + /** Basic-Benutzer des App-Passworts (Anmeldename bei der Ausstellung); null = ncUserId. */ + ncLoginName: string | null; + ncDisplayName: string | null; + encryptedAppPassword: string; + status: 'ACTIVE' | 'EXPIRED'; + connectedVia: ConnectMethod; + createdAt: Date; + updatedAt: Date; +} + +export type FlowPollResult = + | { state: 'pending' } + | { state: 'connected' } + | { state: 'failed'; code: string; message: string }; + +/** Erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert: Zugangsschluessel der Aufrufsperre. */ +export function credentialKeyOf(encryptedAppPassword: string): string { + return createHash('sha256').update(encryptedAppPassword).digest('hex').slice(0, 16); +} + +/** + * Konto je Benutzer (quick-261008-mzu): verbinden mit Passwort oder per + * Browser-Anmeldung (Login Flow v2), trennen mit Widerruf, Sitzung fuer die + * Dateiaufrufe. Gesamter Zugriff auf `nextcloudFilesAccount` mit der + * Benutzerkennung aus dem Token liegt ausschliesslich hier — jede Methode + * bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die + * Zeilenregel laesst nur eigene Zeilen zu, und jedes `where` traegt beides. + * + * Geheimnisse: das echte Passwort lebt nur in `connectWithPassword`, das App- + * Passwort wird mit `CryptoService.encrypt` abgelegt und nur in `getSession` + * entschluesselt. Nichts davon steht in einer Antwort, einem Log oder einem + * Fehler. + * + * App-Passwort-Hygiene (D-P): ein frisch ausgestelltes App-Passwort, das nicht + * gespeichert werden konnte, wird sofort widerrufen; beim erneuten Verbinden + * wird das alte (gleiche Adresse) zuerst widerrufen; ein Zugang fuer eine + * andere Adresse wird nie an diese gesendet. + */ +@Injectable() +export class NextcloudFilesAccountService { + private readonly logger = new Logger(NextcloudFilesAccountService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly crypto: CryptoService, + private readonly settings: NextcloudFilesSettingsService, + private readonly guard: NextcloudLoginGuard, + private readonly flows: LoginFlowStore, + private readonly gate: NextcloudCallGate, + @Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport, + ) { + // Nach einem Adresswechsel gelten offene Browser-Anmeldungen nicht mehr. + this.settings.onAddressChange((tenantId) => this.flows.clearTenant(tenantId)); + } + + // --- Zeilenzugriff (jeweils eigener, an Mandant UND Benutzer gebundener Klient) ---------- + + private async findAccount(tenantId: string, userId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const row = await tenantPrisma.nextcloudFilesAccount.findFirst({ where: { tenantId, userId } }); + return (row as AccountRow | null) ?? null; + } + + private async upsertAccount( + tenantId: string, + userId: string, + data: { + baseUrl: string; + ncUserId: string; + ncLoginName: string; + ncDisplayName: string | null; + encryptedAppPassword: string; + connectedVia: ConnectMethod; + }, + ): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await tenantPrisma.nextcloudFilesAccount.upsert({ + where: { tenantId_userId: { tenantId, userId } }, + create: { tenantId, userId, ...data, status: 'ACTIVE' }, + update: { ...data, status: 'ACTIVE' }, + }); + } + + private async deleteAccount(tenantId: string, userId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await tenantPrisma.nextcloudFilesAccount.deleteMany({ where: { tenantId, userId } }); + } + + /** Markiert das eigene Konto als abgelaufen (App-Passwort wurde von Nextcloud abgelehnt). */ + async markExpired(tenantId: string, userId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await tenantPrisma.nextcloudFilesAccount.updateMany({ + where: { tenantId, userId, status: 'ACTIVE' }, + data: { status: 'EXPIRED' }, + }); + } + + // --- Stand ------------------------------------------------------------------------------ + + async getStatus(tenantId: string, userId: string): Promise { + const base = await this.settings.getStatus(tenantId); + if (!base.configured) return { ...base, account: null }; + const row = await this.findAccount(tenantId, userId); + if (!row) return { ...base, account: null }; + const expired = + row.status !== 'ACTIVE' || + row.baseUrl !== base.serverUrl || + this.gate.isDead(credentialKeyOf(row.encryptedAppPassword)); + const account: NextcloudFilesAccountView = { + connected: !expired, + expired, + status: expired ? 'EXPIRED' : 'ACTIVE', + ncUserId: row.ncUserId, + displayName: row.ncDisplayName, + connectedVia: row.connectedVia, + connectedAt: row.updatedAt.toISOString(), + }; + return { ...base, account }; + } + + // --- Verbinden mit Passwort ------------------------------------------------------------------- + + async connectWithPassword( + tenantId: string, + userId: string, + loginName: string, + password: string, + ): Promise { + const baseUrl = await this.requireBaseUrl(tenantId); + const scope = new URL(baseUrl).origin; + this.guard.checkPasswordAttempt(userId, scope); + + const issued = await getAppPassword(this.transport, this.gate, baseUrl, loginName, password); + if (!issued.ok) { + // 401 ist doppeldeutig (falsches Passwort oder Zwei-Faktor) und zaehlt bei Nextcloud als Fehlanmeldung. + if (issued.kind === 'credentials') this.guard.recordFailure(userId, scope); + throw authFailureToException(issued); + } + + const ncUser = await getCurrentUser( + this.transport, + this.gate, + baseUrl, + loginName, + issued.appPassword, + ); + if (!ncUser.ok) { + await this.revokeFresh(baseUrl, loginName, issued.appPassword); + throw authFailureToException(unexpectedCredentials(ncUser)); + } + + await this.storeAppPassword( + tenantId, + userId, + baseUrl, + loginName, + ncUser, + issued.appPassword, + 'PASSWORD', + ); + this.guard.recordSuccess(userId); + return this.getStatus(tenantId, userId); + } + + // --- Verbinden im Browser (Login Flow v2) ------------------------------------------------------ + + async startFlow( + tenantId: string, + userId: string, + ): Promise<{ flowId: string; loginUrl: string; expiresAt: string }> { + const baseUrl = await this.requireBaseUrl(tenantId); + this.guard.checkFlowStart(userId); + const started = await startLoginFlow(this.transport, this.gate, baseUrl); + if (!started.ok) throw authFailureToException(started); + const entry = this.flows.create(tenantId, userId, baseUrl, started.pollToken); + return { + flowId: entry.flowId, + loginUrl: started.loginUrl, + expiresAt: new Date(entry.expiresAt).toISOString(), + }; + } + + async pollFlow(tenantId: string, userId: string, flowId: string): Promise { + const found = this.flows.lookup(flowId, tenantId, userId); + if (found.state === 'missing') throw ncErrorDefault('notFound'); + if (found.state === 'expired') { + this.flows.remove(flowId); + throw ncErrorDefault('flowExpired'); + } + const entry = found.entry; + + // Die Adresse darf sich seit dem Start nicht geaendert haben. + const current = await this.settings.getBaseUrl(tenantId); + if (current !== entry.baseUrl) { + this.flows.remove(flowId); + throw ncErrorDefault('flowExpired'); + } + + if (!this.flows.shouldPoll(entry)) return { state: 'pending' }; + this.flows.markPolled(entry); + + const polled = await pollLoginFlow(this.transport, this.gate, entry.baseUrl, entry.pollToken); + if (!polled.ok) throw authFailureToException(polled); + if (polled.state === 'pending') return { state: 'pending' }; + + // Bestaetigt. Der Ablauf ist verbraucht (Nextcloud gibt das Ergebnis nur einmal heraus). + const stillOpen = this.flows.get(flowId, tenantId, userId) !== undefined; + this.flows.remove(flowId); + const { loginName, appPassword } = polled; + if (!stillOpen) { + // Zwischenzeitlich abgebrochen: der frisch ausgestellte Zugang darf nirgends liegen bleiben. + await this.revokeFresh(entry.baseUrl, loginName, appPassword); + return this.failed(ncErrorDefault('flowExpired')); + } + + const ncUser = await getCurrentUser( + this.transport, + this.gate, + entry.baseUrl, + loginName, + appPassword, + ); + if (!ncUser.ok) { + await this.revokeFresh(entry.baseUrl, loginName, appPassword); + return this.failed(authFailureToException(unexpectedCredentials(ncUser))); + } + try { + await this.storeAppPassword( + tenantId, + userId, + entry.baseUrl, + loginName, + ncUser, + appPassword, + 'LOGIN_FLOW', + ); + } catch (err) { + return this.failed(err); + } + return { state: 'connected' }; + } + + async cancelFlow(tenantId: string, userId: string, flowId: string): Promise<{ cancelled: true }> { + const found = this.flows.lookup(flowId, tenantId, userId); + if (found.state === 'missing') throw ncErrorDefault('notFound'); + this.flows.remove(flowId); + return { cancelled: true }; + } + + private failed(err: unknown): FlowPollResult { + if (err instanceof HttpException) { + const body = err.getResponse() as { code?: string; message?: string }; + return { + state: 'failed', + code: body.code ?? 'nextcloudError', + message: body.message ?? ncErrorDefault('nextcloudError').message, + }; + } + const fallback = ncErrorDefault('nextcloudError'); + return { state: 'failed', code: 'nextcloudError', message: fallback.message }; + } + + // --- Trennen ---------------------------------------------------------------------------------- + + async disconnect(tenantId: string, userId: string): Promise<{ disconnected: true }> { + const row = await this.findAccount(tenantId, userId); + if (!row) throw ncErrorDefault('notConnected'); + + const current = await this.settings.getBaseUrl(tenantId); + // Widerrufen nur dort, wo der Zugang gilt: aktives Konto UND gleiche Adresse (nie an einen anderen Host). + if (row.status === 'ACTIVE' && current !== null && current === row.baseUrl) { + let appPassword: string | null = null; + try { + appPassword = this.crypto.decrypt(row.encryptedAppPassword); + } catch { + this.logger.error( + `App-Passwort eines Kontos ließ sich nicht entschlüsseln (Mandant ${tenantId}); Konto wird ohne Widerruf entfernt`, + ); + } + if (appPassword !== null) { + await this.revokeBestEffort( + row.baseUrl, + basicUserOf(row), + appPassword, + credentialKeyOf(row.encryptedAppPassword), + ); + } + } + await this.deleteAccount(tenantId, userId); + return { disconnected: true }; + } + + // --- Sitzung fuer die Dateiaufrufe -------------------------------------------------------------- + + async getSession(tenantId: string, userId: string): Promise { + const baseUrl = await this.requireBaseUrl(tenantId); + const row = await this.findAccount(tenantId, userId); + if (!row) throw ncErrorDefault('notConnected'); + if (row.status !== 'ACTIVE' || row.baseUrl !== baseUrl) { + throw ncErrorDefault('connectionExpired'); + } + const credentialKey = credentialKeyOf(row.encryptedAppPassword); + if (this.gate.isDead(credentialKey)) { + await this.markExpired(tenantId, userId); + throw ncErrorDefault('connectionExpired'); + } + let appPassword: string; + try { + appPassword = this.crypto.decrypt(row.encryptedAppPassword); + } catch { + this.logger.error(`Gespeichertes App-Passwort ist nicht lesbar (Mandant ${tenantId})`); + throw ncErrorDefault('accountBroken'); + } + return { + baseUrl: row.baseUrl, + ncUserId: row.ncUserId, + authorization: basicAuth(basicUserOf(row), appPassword), + credentialKey, + }; + } + + // --- Hilfen --------------------------------------------------------------------------------------- + + private async requireBaseUrl(tenantId: string): Promise { + const baseUrl = await this.settings.getBaseUrl(tenantId); + if (baseUrl === null) throw ncErrorDefault('notConfigured'); + return baseUrl; + } + + /** + * App-Passwort ablegen (D-P): zuerst das alte Passwort derselben Adresse + * widerrufen, dann verschluesseln und speichern. Scheitert etwas, wird das + * FRISCHE Passwort sofort widerrufen und der Fehler weitergegeben. + */ + private async storeAppPassword( + tenantId: string, + userId: string, + baseUrl: string, + loginName: string, + ncUser: { id: string; displayName: string | null }, + appPassword: string, + method: ConnectMethod, + ): Promise { + try { + await this.revokePrevious(tenantId, userId, baseUrl); + const encryptedAppPassword = this.crypto.encrypt(appPassword); + await this.upsertAccount(tenantId, userId, { + baseUrl, + ncUserId: ncUser.id, + ncLoginName: loginName, + ncDisplayName: ncUser.displayName, + encryptedAppPassword, + connectedVia: method, + }); + } catch (err) { + await this.revokeFresh(baseUrl, loginName, appPassword); + throw err; + } + } + + /** Das alte App-Passwort derselben Adresse widerrufen (best effort, nie ein Fehler nach aussen). */ + private async revokePrevious(tenantId: string, userId: string, baseUrl: string): Promise { + let old: AccountRow | null; + try { + old = await this.findAccount(tenantId, userId); + } catch { + return; + } + if (!old || old.baseUrl !== baseUrl) return; + let oldPassword: string; + try { + oldPassword = this.crypto.decrypt(old.encryptedAppPassword); + } catch { + this.logger.warn(`Altes App-Passwort nicht lesbar (Mandant ${tenantId}); kein Widerruf`); + return; + } + await this.revokeBestEffort( + old.baseUrl, + basicUserOf(old), + oldPassword, + credentialKeyOf(old.encryptedAppPassword), + ); + } + + private async revokeFresh( + baseUrl: string, + loginName: string, + appPassword: string, + ): Promise { + await this.revokeBestEffort(baseUrl, loginName, appPassword); + } + + private async revokeBestEffort( + baseUrl: string, + loginName: string, + appPassword: string, + credentialKey?: string, + ): Promise { + try { + const res = await revokeAppPassword( + this.transport, + this.gate, + baseUrl, + loginName, + appPassword, + credentialKey, + ); + if (!res.ok) { + this.logger.warn(`Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)})`); + } + } catch { + this.logger.warn('Widerruf eines App-Passworts fehlgeschlagen'); + } + } +} + +/** + * Der Basic-Benutzer eines App-Passworts ist der Anmeldename der Ausstellung + * (gemessen: mit der E-Mail-Adresse ausgestellt, antwortet Nextcloud auf die + * Kennung mit 401). Konten vor dieser Spalte haben keinen: dann gilt die Kennung. + */ +function basicUserOf(row: Pick): string { + return row.ncLoginName ?? row.ncUserId; +} + +function failureLabel(failure: AuthFailure): string { + return authFailureCode(failure); +} + +/** + * Ein 401 auf `cloud/user` mit einem GERADE ausgestellten App-Passwort ist kein + * "falsches Passwort" fuer den Benutzer, sondern eine unerwartete Antwort. + */ +function unexpectedCredentials(failure: AuthFailure): AuthFailure { + return failure.kind === 'credentials' ? { ...failure, kind: 'upstream' } : failure; +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts index 123f67b..e20a05e 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts @@ -73,6 +73,34 @@ describe('NextcloudFilesController — Metadaten', () => { expect(route('getSettings')).toEqual([0, 'settings']); expect(route('saveSettings')).toEqual([2, 'settings']); expect(route('testSettings')).toEqual([1, 'settings/test']); + expect(route('connectPassword')).toEqual([1, 'connect/password']); + expect(route('startFlow')).toEqual([1, 'connect/flow']); + expect(route('disconnect')).toEqual([3, 'connect']); + expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']); + expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']); + }); + + it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => { + for (const name of ['connectPassword', 'startFlow', 'pollFlow', 'cancelFlow', 'disconnect']) { + expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined(); + expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined(); + } + }); + + it('Passwort- und Browser-Anmeldung antworten 200, nicht 201', () => { + expect(Reflect.getMetadata('__httpCode__', proto.connectPassword)).toBe(200); + expect(Reflect.getMetadata('__httpCode__', proto.startFlow)).toBe(200); + }); + + it('pollFlow und cancelFlow stehen nach allen statischen Handlern', () => { + const names = routeHandlers(); + const staticLast = Math.max( + ...names + .filter((n) => !String(Reflect.getMetadata('path', proto[n])).includes(':')) + .map((n) => names.indexOf(n)), + ); + expect(names.indexOf('pollFlow')).toBeGreaterThan(staticLast); + expect(names.indexOf('cancelFlow')).toBeGreaterThan(staticLast); }); it('POST settings/test antwortet 200, nicht 201', () => { @@ -87,34 +115,85 @@ describe('NextcloudFilesController — Routen-Reihenfolge (statisch vor Paramete }); describe('NextcloudFilesController — Delegation', () => { + const FLOW = '8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c'; + const userReq = (tenantId: string | undefined, userId: string | undefined) => + ({ tenantId, user: userId ? { id: userId } : undefined }) as any; + function makeSettings() { return { - getStatus: vi.fn(async (..._a: unknown[]) => ({ configured: true })), getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })), saveSettings: vi.fn(async (..._a: unknown[]) => ({})), testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })), }; } - it('reicht den Mandanten aus dem Token weiter, nie aus dem Body', async () => { + function makeAccount() { + return { + getStatus: vi.fn(async (..._a: unknown[]) => ({ configured: true })), + connectWithPassword: vi.fn(async (..._a: unknown[]) => ({ configured: true })), + startFlow: vi.fn(async (..._a: unknown[]) => ({})), + pollFlow: vi.fn(async (..._a: unknown[]) => ({ state: 'pending' })), + cancelFlow: vi.fn(async (..._a: unknown[]) => ({ cancelled: true })), + disconnect: vi.fn(async (..._a: unknown[]) => ({ disconnected: true })), + }; + } + + it('reicht Mandant und Benutzer aus dem Token weiter, nie aus dem Body', async () => { const settings = makeSettings(); - const controller = new NextcloudFilesController(settings as any); - await controller.getStatus(req('t1')); + const account = makeAccount(); + const controller = new NextcloudFilesController(settings as any, account as any); + await controller.getStatus(userReq('t1', 'u1')); await controller.getSettings(req('t1')); await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any); await controller.testSettings(req('t1'), { baseUrl: 'https://x.example' } as any); - expect(settings.getStatus).toHaveBeenCalledWith('t1'); + await controller.connectPassword(userReq('t1', 'u1'), { + loginName: ' anna ', + password: 'geheim', + userId: 'fremd', + } as any); + await controller.startFlow(userReq('t1', 'u1')); + await controller.pollFlow(userReq('t1', 'u1'), FLOW); + await controller.cancelFlow(userReq('t1', 'u1'), FLOW); + await controller.disconnect(userReq('t1', 'u1')); + expect(account.getStatus).toHaveBeenCalledWith('t1', 'u1'); expect(settings.getSettings).toHaveBeenCalledWith('t1'); expect(settings.saveSettings).toHaveBeenCalledWith('t1', { baseUrl: 'https://x.example' }); expect(settings.testAddress).toHaveBeenCalledWith('https://x.example'); + expect(account.connectWithPassword).toHaveBeenCalledWith('t1', 'u1', 'anna', 'geheim'); + expect(account.startFlow).toHaveBeenCalledWith('t1', 'u1'); + expect(account.pollFlow).toHaveBeenCalledWith('t1', 'u1', FLOW); + expect(account.cancelFlow).toHaveBeenCalledWith('t1', 'u1', FLOW); + expect(account.disconnect).toHaveBeenCalledWith('t1', 'u1'); }); it('ohne Mandantenkontext: ForbiddenException', async () => { - const controller = new NextcloudFilesController(makeSettings() as any); - await expect(controller.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException); + const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any); + await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf( + ForbiddenException, + ); await expect(controller.getSettings(req(undefined))).rejects.toBeInstanceOf(ForbiddenException); await expect( controller.testSettings(req(undefined), { baseUrl: 'https://x.example' } as any), ).rejects.toBeInstanceOf(ForbiddenException); }); + + it('ohne Benutzer im Token: ForbiddenException, kein Aufruf des Kontodienstes', async () => { + const account = makeAccount(); + const controller = new NextcloudFilesController(makeSettings() as any, account as any); + await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf( + ForbiddenException, + ); + await expect( + controller.connectPassword(userReq('t1', undefined), { + loginName: 'anna', + password: 'x', + } as any), + ).rejects.toBeInstanceOf(ForbiddenException); + await expect(controller.disconnect(userReq('t1', undefined))).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(account.getStatus).not.toHaveBeenCalled(); + expect(account.connectWithPassword).not.toHaveBeenCalled(); + expect(account.disconnect).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts index d7d4183..f549b3b 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts @@ -1,19 +1,24 @@ import { Body, Controller, + Delete, ForbiddenException, Get, HttpCode, + Param, + ParseUUIDPipe, Post, Put, Req, } from '@nestjs/common'; import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { ModuleManage, UseModule } from '../module-registry/module.guard'; +import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto'; import { SaveNextcloudFilesSettingsDto, TestNextcloudFilesSettingsDto, } from './dto/nextcloud-files-settings.dto'; +import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; /** @@ -50,7 +55,10 @@ import { NextcloudFilesSettingsService } from './nextcloud-files-settings.servic @Controller('modules/nextcloud-files') @UseModule('nextcloud-files') export class NextcloudFilesController { - constructor(private readonly settings: NextcloudFilesSettingsService) {} + constructor( + private readonly settings: NextcloudFilesSettingsService, + private readonly account: NextcloudFilesAccountService, + ) {} private requireTenantId(req: AuthenticatedRequest): string { const tenantId = req.tenantId; @@ -60,9 +68,18 @@ export class NextcloudFilesController { return tenantId; } + /** Die Benutzerkennung kommt NUR aus dem Token, nie aus Body oder Query. */ + private requireUserId(req: AuthenticatedRequest): string { + const userId = req.user?.id; + if (!userId) { + throw new ForbiddenException('Kein Benutzerkontext'); + } + return userId; + } + @Get('status') async getStatus(@Req() req: AuthenticatedRequest) { - return this.settings.getStatus(this.requireTenantId(req)); + return this.account.getStatus(this.requireTenantId(req), this.requireUserId(req)); } @Get('settings') @@ -84,4 +101,46 @@ export class NextcloudFilesController { this.requireTenantId(req); return this.settings.testAddress(dto.baseUrl); } + + // --- Konto verbinden (Benutzen) ------------------------------------------------------ + + @Post('connect/password') + @HttpCode(200) + async connectPassword(@Req() req: AuthenticatedRequest, @Body() dto: ConnectPasswordDto) { + return this.account.connectWithPassword( + this.requireTenantId(req), + this.requireUserId(req), + dto.loginName.trim(), + dto.password, + ); + } + + @Post('connect/flow') + @HttpCode(200) + async startFlow(@Req() req: AuthenticatedRequest) { + return this.account.startFlow(this.requireTenantId(req), this.requireUserId(req)); + } + + @Delete('connect') + async disconnect(@Req() req: AuthenticatedRequest) { + return this.account.disconnect(this.requireTenantId(req), this.requireUserId(req)); + } + + // --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) --------------- + + @Get('connect/flow/:flowId') + async pollFlow( + @Req() req: AuthenticatedRequest, + @Param('flowId', new ParseUUIDPipe()) flowId: string, + ) { + return this.account.pollFlow(this.requireTenantId(req), this.requireUserId(req), flowId); + } + + @Delete('connect/flow/:flowId') + async cancelFlow( + @Req() req: AuthenticatedRequest, + @Param('flowId', new ParseUUIDPipe()) flowId: string, + ) { + return this.account.cancelFlow(this.requireTenantId(req), this.requireUserId(req), flowId); + } } diff --git a/apps/api/src/nextcloud-files/nextcloud-files.module.ts b/apps/api/src/nextcloud-files/nextcloud-files.module.ts index 2158792..3eb887c 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.module.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.module.ts @@ -4,12 +4,14 @@ import { ModuleRegistryService } from '../module-registry/module-registry.servic import { NextcloudCallGate } from './nextcloud-call-gate'; import { NextcloudFilesController } from './nextcloud-files.controller'; import { seedNextcloudFilesModule } from './nextcloud-files.seed'; +import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; import { defaultStatusFetcher, NEXTCLOUD_STATUS_FETCHER, NextcloudFilesSettingsService, } from './nextcloud-files-settings.service'; import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http'; +import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; /** * Modul "Dateien" (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in @@ -23,11 +25,19 @@ import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http'; controllers: [NextcloudFilesController], providers: [ NextcloudFilesSettingsService, + NextcloudFilesAccountService, + NextcloudLoginGuard, + LoginFlowStore, NextcloudCallGate, { provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport }, { provide: NEXTCLOUD_STATUS_FETCHER, useValue: defaultStatusFetcher }, ], - exports: [NextcloudCallGate, NextcloudFilesSettingsService, NEXTCLOUD_TRANSPORT], + exports: [ + NextcloudCallGate, + NextcloudFilesSettingsService, + NextcloudFilesAccountService, + NEXTCLOUD_TRANSPORT, + ], }) export class NextcloudFilesModule implements OnModuleInit { private readonly logger = new Logger(NextcloudFilesModule.name); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.types.ts b/apps/api/src/nextcloud-files/nextcloud-files.types.ts index 4f69d53..a4dd8cf 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.types.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.types.ts @@ -224,9 +224,13 @@ export interface NextcloudFilesAccountView { connected: boolean; /** true, wenn das Konto abgelaufen ist (Adresswechsel, widerrufen, 401). */ expired: boolean; + /** `ACTIVE`, oder `EXPIRED` bei gespeichertem Ablauf, anderer Adresse oder totem Zugangsschluessel. */ + status: 'ACTIVE' | 'EXPIRED'; ncUserId: string | null; displayName: string | null; connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null; + /** Zeitpunkt der Verbindung (ISO), nie ein Geheimnis. */ + connectedAt: string | null; } export interface NextcloudFilesStatusView { diff --git a/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts b/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts new file mode 100644 index 0000000..a4c611d --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { + FLOW_MAX_TOTAL, + FLOW_TTL_MS, + LoginFlowStore, + NextcloudLoginGuard, +} from './nextcloud-login-guard'; + +const MIN = 60 * 1000; + +function codeOf(fn: () => unknown): { status?: number; body?: any } { + try { + fn(); + } catch (e) { + return { status: (e as any).getStatus?.(), body: (e as any).getResponse?.() }; + } + return {}; +} + +function makeGuard() { + const guard = new NextcloudLoginGuard(); + const clock = { t: 1_000_000 }; + guard.now = () => clock.t; + return { guard, clock }; +} + +describe('NextcloudLoginGuard — Passwort-Fehlversuche', () => { + it('3 Fehlversuche von u1: der 4. Versuch ist 429 mit Wartezeit, u2 darf noch', () => { + const { guard, clock } = makeGuard(); + for (let i = 0; i < 3; i++) { + guard.checkPasswordAttempt('u1'); + guard.recordFailure('u1'); + clock.t += 1000; + } + const blocked = codeOf(() => guard.checkPasswordAttempt('u1')); + expect(blocked.status).toBe(429); + expect(blocked.body.code).toBe('tooManyAttempts'); + expect(blocked.body.retryAfterSeconds).toBeGreaterThan(0); + expect(blocked.body.retryAfterSeconds).toBeLessThanOrEqual(15 * 60); + expect(codeOf(() => guard.checkPasswordAttempt('u2')).status).toBeUndefined(); + }); + + it('nach 15 Minuten darf u1 wieder', () => { + const { guard, clock } = makeGuard(); + for (let i = 0; i < 3; i++) guard.recordFailure('u1'); + expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBe(429); + clock.t += 15 * MIN + 1; + expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined(); + }); + + it('8 Fehlversuche verteilt auf Benutzer binnen 30 Minuten sperren jeden', () => { + const { guard, clock } = makeGuard(); + for (let i = 0; i < 8; i++) { + guard.recordFailure(`u${i}`); + clock.t += 60 * 1000; + } + const blocked = codeOf(() => guard.checkPasswordAttempt('neu')); + expect(blocked.status).toBe(429); + expect(blocked.body.code).toBe('tooManyAttempts'); + clock.t += 30 * MIN; + expect(codeOf(() => guard.checkPasswordAttempt('neu')).status).toBeUndefined(); + }); + + it('recordSuccess loescht nur die Fehlversuche dieses Benutzers', () => { + const { guard } = makeGuard(); + for (let i = 0; i < 3; i++) { + guard.recordFailure('u1'); + guard.recordFailure('u2'); + } + guard.recordSuccess('u1'); + expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined(); + expect(codeOf(() => guard.checkPasswordAttempt('u2')).status).toBe(429); + }); + + it('getrennte Nextcloud-Ursprünge teilen die Serversperre nicht', () => { + const { guard } = makeGuard(); + for (let i = 0; i < 8; i++) guard.recordFailure(`u${i}`, 'http://a.example'); + expect(codeOf(() => guard.checkPasswordAttempt('x', 'http://a.example')).status).toBe(429); + expect( + codeOf(() => guard.checkPasswordAttempt('x', 'http://b.example')).status, + ).toBeUndefined(); + }); +}); + +describe('NextcloudLoginGuard — Start der Browser-Anmeldung', () => { + it('der 11. Start eines Benutzers binnen 10 Minuten ist 429, andere Benutzer nicht', () => { + const { guard, clock } = makeGuard(); + for (let i = 0; i < 10; i++) { + guard.checkFlowStart('u1'); + clock.t += 1000; + } + const blocked = codeOf(() => guard.checkFlowStart('u1')); + expect(blocked.status).toBe(429); + expect(blocked.body.retryAfterSeconds).toBeGreaterThan(0); + expect(codeOf(() => guard.checkFlowStart('u2')).status).toBeUndefined(); + clock.t += 10 * MIN; + expect(codeOf(() => guard.checkFlowStart('u1')).status).toBeUndefined(); + }); + + it('beruehrt die Fehlerzaehler nie', () => { + const { guard } = makeGuard(); + for (let i = 0; i < 10; i++) guard.checkFlowStart('u1'); + expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined(); + }); +}); + +describe('LoginFlowStore', () => { + function makeStore() { + const store = new LoginFlowStore(); + const clock = { t: 5_000_000 }; + store.now = () => clock.t; + return { store, clock }; + } + + it('create liefert eine uuid; ein zweiter Start desselben Benutzers ersetzt den ersten', () => { + const { store } = makeStore(); + const a = store.create('t1', 'u1', 'http://c.example', 'tok-a'); + expect(a.flowId).toMatch(/^[0-9a-f-]{36}$/); + const b = store.create('t1', 'u1', 'http://c.example', 'tok-b'); + expect(store.get(a.flowId, 't1', 'u1')).toBeUndefined(); + expect(store.get(b.flowId, 't1', 'u1')?.pollToken).toBe('tok-b'); + }); + + it('fremder Benutzer oder Mandant: nichts (wie unbekannt)', () => { + const { store } = makeStore(); + const a = store.create('t1', 'u1', 'http://c.example', 'tok'); + expect(store.get(a.flowId, 't1', 'u2')).toBeUndefined(); + expect(store.get(a.flowId, 't2', 'u1')).toBeUndefined(); + expect(store.lookup(a.flowId, 't1', 'u2')).toEqual({ state: 'missing' }); + }); + + it('nach 20 Minuten abgelaufen', () => { + const { store, clock } = makeStore(); + const a = store.create('t1', 'u1', 'http://c.example', 'tok'); + clock.t += FLOW_TTL_MS - 1; + expect(store.lookup(a.flowId, 't1', 'u1').state).toBe('ok'); + clock.t += 2; + expect(store.lookup(a.flowId, 't1', 'u1')).toEqual({ state: 'expired' }); + expect(store.get(a.flowId, 't1', 'u1')).toBeUndefined(); + }); + + it('der 201. Ablauf ist 503 tooManyFlows', () => { + const { store } = makeStore(); + for (let i = 0; i < FLOW_MAX_TOTAL; i++) store.create('t1', `u${i}`, 'http://c.example', 'tok'); + const res = codeOf(() => store.create('t1', 'neu', 'http://c.example', 'tok')); + expect(res.status).toBe(503); + expect(res.body.code).toBe('tooManyFlows'); + // Ein bestehender Benutzer ersetzt seinen Ablauf weiterhin. + expect( + codeOf(() => store.create('t1', 'u0', 'http://c.example', 'tok')).status, + ).toBeUndefined(); + }); + + it('shouldPoll ist binnen 1,5 s nach der letzten Abfrage false', () => { + const { store, clock } = makeStore(); + const a = store.create('t1', 'u1', 'http://c.example', 'tok'); + expect(store.shouldPoll(a)).toBe(true); + store.markPolled(a); + clock.t += 1000; + expect(store.shouldPoll(a)).toBe(false); + clock.t += 500; + expect(store.shouldPoll(a)).toBe(true); + }); + + it('clearTenant verwirft nur die Ablaeufe dieser Organisation', () => { + const { store } = makeStore(); + const a = store.create('t1', 'u1', 'http://c.example', 'tok'); + const b = store.create('t2', 'u2', 'http://c.example', 'tok'); + store.clearTenant('t1'); + expect(store.get(a.flowId, 't1', 'u1')).toBeUndefined(); + expect(store.get(b.flowId, 't2', 'u2')).toBeDefined(); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-login-guard.ts b/apps/api/src/nextcloud-files/nextcloud-login-guard.ts new file mode 100644 index 0000000..b9cbcae --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-login-guard.ts @@ -0,0 +1,210 @@ +import { randomUUID } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { ncErrorDefault } from './nextcloud-files.types'; + +/** + * Anmeldebremse des Moduls "Nextcloud-Dateien" (quick-261008-mzu, D-E/D-F, L-04). + * + * WARUM: Alle Tessera-Benutzer erreichen die Nextcloud von EINER Server-Adresse. + * Die Brute-Force-Erkennung der Nextcloud sperrt je Adresse nach 10 + * Fehlanmeldungen in 30 Minuten — fuer ALLE Benutzer zugleich, und jeder + * weitere Versuch waehrend der Sperre verlaengert sie. Tessera bremst deshalb + * selbst, bevor es dazu kommt: hoechstens 3 Fehlversuche je Benutzer in 15 + * Minuten und 8 fuer den ganzen Server in 30 Minuten (jeweils unter der + * Schwelle der Nextcloud). Ist die Grenze erreicht, antwortet Tessera mit 429 + * `tooManyAttempts`, OHNE Nextcloud anzusprechen. Die Zaehler liegen im + * Arbeitsspeicher; ein Neustart vergisst sie (hinnehmbar — der Schutz der + * Nextcloud selbst und die Aufrufsperre bleiben bestehen). Ein 429 der + * Nextcloud behandelt die Aufrufsperre (`NextcloudCallGate`), nicht diese Klasse. + * + * Der Login Flow v2 zaehlt bei der Nextcloud nicht als Fehlanmeldung (gemessen), + * bekommt deshalb nur eine eigene Startgrenze (10 je Benutzer in 10 Minuten) + * und beruehrt die Fehlerzaehler nie. + */ + +export const USER_FAILURE_LIMIT = 3; +export const USER_FAILURE_WINDOW_MS = 15 * 60 * 1000; +export const SERVER_FAILURE_LIMIT = 8; +export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000; +export const FLOW_START_LIMIT = 10; +export const FLOW_START_WINDOW_MS = 10 * 60 * 1000; + +function prune(list: number[], now: number, windowMs: number): number[] { + return list.filter((t) => now - t < windowMs); +} + +function tooMany(retryAfterMs: number) { + return ncErrorDefault('tooManyAttempts', { + retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)), + }); +} + +@Injectable() +export class NextcloudLoginGuard { + /** Zeitquelle in Millisekunden; Tests ersetzen sie. */ + now: () => number = () => Date.now(); + + private readonly userFailures = new Map(); + private readonly serverFailures = new Map(); + private readonly flowStarts = new Map(); + + /** + * Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429 + * `tooManyAttempts` mit `retryAfterSeconds` (bis der aelteste gezaehlte + * Fehlversuch das Fenster verlaesst). `scope` trennt Server, die verschiedene + * Nextclouds ansprechen (Standard: eine gemeinsame Sperre). + */ + checkPasswordAttempt(userId: string, scope = ''): void { + const now = this.now(); + const mine = prune(this.userFailures.get(userId) ?? [], now, USER_FAILURE_WINDOW_MS); + const server = prune(this.serverFailures.get(scope) ?? [], now, SERVER_FAILURE_WINDOW_MS); + this.userFailures.set(userId, mine); + this.serverFailures.set(scope, server); + + let waitMs = 0; + if (mine.length >= USER_FAILURE_LIMIT) { + waitMs = Math.max(waitMs, mine[0] + USER_FAILURE_WINDOW_MS - now); + } + if (server.length >= SERVER_FAILURE_LIMIT) { + waitMs = Math.max(waitMs, server[0] + SERVER_FAILURE_WINDOW_MS - now); + } + if (waitMs > 0) throw tooMany(waitMs); + } + + recordFailure(userId: string, scope = ''): void { + const now = this.now(); + const mine = prune(this.userFailures.get(userId) ?? [], now, USER_FAILURE_WINDOW_MS); + mine.push(now); + this.userFailures.set(userId, mine); + const server = prune(this.serverFailures.get(scope) ?? [], now, SERVER_FAILURE_WINDOW_MS); + server.push(now); + this.serverFailures.set(scope, server); + } + + /** Eine gelungene Anmeldung loescht nur die Fehlversuche dieses Benutzers. */ + recordSuccess(userId: string): void { + this.userFailures.delete(userId); + } + + /** Zaehlt einen Start der Browser-Anmeldung; der 11. in 10 Minuten wird abgewiesen. */ + checkFlowStart(userId: string): void { + const now = this.now(); + const starts = prune(this.flowStarts.get(userId) ?? [], now, FLOW_START_WINDOW_MS); + if (starts.length >= FLOW_START_LIMIT) { + this.flowStarts.set(userId, starts); + throw tooMany(starts[0] + FLOW_START_WINDOW_MS - now); + } + starts.push(now); + this.flowStarts.set(userId, starts); + } +} + +// --- Browser-Anmeldung (Login Flow v2) ----------------------------------------- + +export const FLOW_TTL_MS = 20 * 60 * 1000; +export const FLOW_MAX_TOTAL = 200; +export const FLOW_POLL_MIN_INTERVAL_MS = 1500; +/** Abgelaufene Eintraege bleiben noch kurz, damit die Abfrage 410 statt 404 melden kann. */ +const FLOW_TOMBSTONE_MS = 5 * 60 * 1000; + +export interface FlowEntry { + flowId: string; + tenantId: string; + userId: string; + /** Adresse, fuer die der Ablauf gestartet wurde. */ + baseUrl: string; + /** Abfrage-Token der Nextcloud — verlaesst den Server nie. */ + pollToken: string; + expiresAt: number; + lastPollAt: number; +} + +export type FlowLookup = + | { state: 'ok'; entry: FlowEntry } + | { state: 'expired' } + | { state: 'missing' }; + +/** + * Offene Browser-Anmeldungen im Arbeitsspeicher (D-F): hoechstens eine je + * Benutzer (ein neuer Start ersetzt die alte), hoechstens 200 insgesamt, + * 20 Minuten Lebensdauer. Ein Neustart der API verliert offene Abläufe — der + * Benutzer startet dann einfach neu. Fremde Kennungen (anderer Benutzer oder + * Mandant) sind nicht von unbekannten zu unterscheiden. + */ +@Injectable() +export class LoginFlowStore { + now: () => number = () => Date.now(); + + private readonly flows = new Map(); + + private prune(): void { + const now = this.now(); + for (const [id, entry] of this.flows) { + if (now >= entry.expiresAt + FLOW_TOMBSTONE_MS) this.flows.delete(id); + } + } + + private liveCount(): number { + const now = this.now(); + let n = 0; + for (const entry of this.flows.values()) if (now < entry.expiresAt) n += 1; + return n; + } + + create(tenantId: string, userId: string, baseUrl: string, pollToken: string): FlowEntry { + this.prune(); + // Ein neuer Start ersetzt den alten desselben Benutzers. + for (const [id, entry] of this.flows) { + if (entry.tenantId === tenantId && entry.userId === userId) this.flows.delete(id); + } + if (this.liveCount() >= FLOW_MAX_TOTAL) throw ncErrorDefault('tooManyFlows'); + const now = this.now(); + const entry: FlowEntry = { + flowId: randomUUID(), + tenantId, + userId, + baseUrl, + pollToken, + expiresAt: now + FLOW_TTL_MS, + lastPollAt: Number.NEGATIVE_INFINITY, + }; + this.flows.set(entry.flowId, entry); + return entry; + } + + lookup(flowId: string, tenantId: string, userId: string): FlowLookup { + this.prune(); + const entry = this.flows.get(flowId); + if (!entry || entry.tenantId !== tenantId || entry.userId !== userId) { + return { state: 'missing' }; + } + if (this.now() >= entry.expiresAt) return { state: 'expired' }; + return { state: 'ok', entry }; + } + + /** Der Eintrag, falls er fuer diesen Benutzer noch lebt (sonst `undefined`). */ + get(flowId: string, tenantId: string, userId: string): FlowEntry | undefined { + const found = this.lookup(flowId, tenantId, userId); + return found.state === 'ok' ? found.entry : undefined; + } + + remove(flowId: string): void { + this.flows.delete(flowId); + } + + /** Hoechstens eine Nextcloud-Abfrage je 1,5 s und Ablauf; schnellere Browser-Abfragen bleiben `pending`. */ + shouldPoll(entry: FlowEntry): boolean { + return this.now() - entry.lastPollAt >= FLOW_POLL_MIN_INTERVAL_MS; + } + + markPolled(entry: FlowEntry): void { + entry.lastPollAt = this.now(); + } + + /** Nach einem Adresswechsel: alle Abläufe der Organisation verwerfen. */ + clearTenant(tenantId: string): void { + for (const [id, entry] of this.flows) { + if (entry.tenantId === tenantId) this.flows.delete(id); + } + } +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/AccountBar.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/AccountBar.tsx new file mode 100644 index 0000000..b4cb7d0 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/AccountBar.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { + disconnectNextcloud, + type NextcloudFilesAccount, + NextcloudFilesRequestError, +} from '@/lib/nextcloud-files-api'; + +/** + * Kontoleiste im Seitenkopf (quick-261008-mzu): zeigt, mit welchem Nextcloud-Konto + * der Benutzer angemeldet ist, und trennt die Verbindung nach einer Rueckfrage. + * Die API widerruft den Zugang bei Nextcloud; die Dateien dort bleiben unveraendert. + */ +export function AccountBar({ + account, + host, + onDisconnected, +}: { + account: NextcloudFilesAccount; + host: string | null; + onDisconnected: () => void; +}) { + const t = useTranslations('nextcloudFiles.account'); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const name = account.displayName ?? account.ncUserId ?? ''; + + const disconnect = async () => { + setBusy(true); + setError(null); + try { + await disconnectNextcloud(); + setConfirming(false); + onDisconnected(); + } catch (err) { + // 409 notConnected: bereits getrennt, die Seite soll den Stand neu laden. + if (err instanceof NextcloudFilesRequestError && err.code === 'notConnected') { + setConfirming(false); + onDisconnected(); + } else { + setError(t('disconnectFailed')); + } + } finally { + setBusy(false); + } + }; + + if (confirming) { + return ( +
+

+ {t('confirm.title')} {t('confirm.body')} +

+ {error && ( +

+ {error} +

+ )} + + +
+ ); + } + + return ( +
+
+

{t('signedInAs', { name })}

+ {host &&

{host}

} +
+ +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx new file mode 100644 index 0000000..70e3338 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx @@ -0,0 +1,332 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { + cancelLoginFlow, + connectWithPassword, + type NextcloudFilesFlowStart, + NextcloudFilesRequestError, + pollLoginFlow, + startLoginFlow, +} from '@/lib/nextcloud-files-api'; + +const POLL_INTERVAL_MS = 2000; + +const INPUT_CLASS = + 'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'; + +interface ErrorState { + code: string | null; + message: string; + retryAfterSeconds: number | null; +} + +/** + * Verbindungsbildschirm (quick-261008-mzu, L-02/L-03): ein Benutzer verbindet sein + * eigenes Nextcloud-Konto. Zwei Wege: + * 1. Benutzername und Passwort. Tessera speichert das Passwort nicht, sondern + * laesst sich einen eigenen Zugang ausstellen. + * 2. "Im Browser anmelden" (Login Flow v2) fuer Konten mit Zwei-Faktor-Anmeldung. + * Der Link zur Nextcloud ist ein gewoehnlicher Verweis, den der Benutzer + * SELBST anklickt — nie ein per Skript nach einem asynchronen Aufruf + * geoeffnetes Fenster (Popup-Sperren; die Desktop-App oeffnet + * `target=_blank` ueber ihren Dokument-Helfer). Waehrenddessen fragt die + * Seite alle 2 s die API, die ihrerseits Nextcloud befragt. + */ +export function ConnectPanel({ + host, + expired, + onConnected, +}: { + host: string | null; + expired: boolean; + onConnected: () => void; +}) { + const t = useTranslations('nextcloudFiles.connect'); + + const [loginName, setLoginName] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const [flow, setFlow] = useState(null); + + const toError = useCallback( + (err: unknown): ErrorState => { + if (err instanceof NextcloudFilesRequestError) { + const retry = Number(err.extra.retryAfterSeconds); + return { + code: err.code, + message: err.message, + retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? retry : null, + }; + } + return { code: null, message: t('errors.generic'), retryAfterSeconds: null }; + }, + [t], + ); + + const errorText = (state: ErrorState): string => { + const minutes = Math.max(1, Math.ceil((state.retryAfterSeconds ?? 60) / 60)); + switch (state.code) { + case 'credentialsOrTwoFactor': + return t('errors.credentialsOrTwoFactor'); + case 'useBrowserLogin': + return t('errors.useBrowserLogin'); + case 'tooManyAttempts': + return t('errors.tooManyAttempts', { minutes }); + case 'nextcloudLocked': + return t('errors.nextcloudLocked', { minutes }); + case 'flowExpired': + return t('errors.flowExpired'); + case 'nextcloudUnavailable': + return t('errors.nextcloudUnavailable'); + case 'tooManyFlows': + return t('errors.tooManyFlows'); + default: + return state.message || t('errors.generic'); + } + }; + + // --- Passwort ---------------------------------------------------------------- + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + const name = loginName.trim(); + if (name === '' || password === '') return; + setBusy(true); + setError(null); + try { + await connectWithPassword({ loginName: name, password }); + setPassword(''); + onConnected(); + } catch (err) { + setError(toError(err)); + } finally { + // Das Passwort bleibt nach keinem Versuch im Formular stehen. + setPassword(''); + setBusy(false); + } + }; + + // --- Browser-Anmeldung ---------------------------------------------------------- + + const flowRef = useRef(null); + flowRef.current = flow; + // Stabile Verweise, damit die Abfrageschleife nicht bei jedem Neuaufbau der Seite neu startet. + const onConnectedRef = useRef(onConnected); + onConnectedRef.current = onConnected; + const toErrorRef = useRef(toError); + toErrorRef.current = toError; + + const begin = async () => { + if (starting || busy) return; + setStarting(true); + setError(null); + try { + setFlow(await startLoginFlow()); + } catch (err) { + setError(toError(err)); + } finally { + setStarting(false); + } + }; + + const cancel = async () => { + const current = flowRef.current; + setFlow(null); + if (current) { + try { + await cancelLoginFlow(current.flowId); + } catch { + // Der Ablauf ist serverseitig ohnehin begrenzt; ein Fehler beim Abbrechen ist unerheblich. + } + } + }; + + useEffect(() => { + if (!flow) return; + const flowId = flow.flowId; + let stopped = false; + let inFlight = false; + + const stop = (next: ErrorState | null) => { + stopped = true; + setFlow(null); + if (next) setError(next); + }; + + const poll = async () => { + if (stopped || inFlight) return; + inFlight = true; + try { + const result = await pollLoginFlow(flowId); + if (stopped) return; + if (result.state === 'connected') { + stopped = true; + setFlow(null); + onConnectedRef.current(); + } else if (result.state === 'failed') { + stop({ code: result.code, message: result.message, retryAfterSeconds: null }); + } + } catch (err) { + if (stopped) return; + if (err instanceof NextcloudFilesRequestError) { + if (err.status === 404 || err.status === 410) { + // Abgelaufen oder unbekannt (z. B. nach einem Neustart der API): von vorn beginnen. + stop({ code: 'flowExpired', message: err.message, retryAfterSeconds: null }); + } else if (err.code === 'nextcloudLocked' || (err.status < 500 && err.status !== 429)) { + stop(toErrorRef.current(err)); + } + // Andere Serverfehler sind meist vorübergehend: beim nächsten Takt erneut versuchen. + } + // Netzwerkaussetzer: ebenfalls beim nächsten Takt erneut versuchen. + } finally { + inFlight = false; + } + }; + + const timer = window.setInterval(() => void poll(), POLL_INTERVAL_MS); + const onVisible = () => { + if (document.visibilityState === 'visible') void poll(); + }; + document.addEventListener('visibilitychange', onVisible); + return () => { + stopped = true; + window.clearInterval(timer); + document.removeEventListener('visibilitychange', onVisible); + }; + }, [flow]); + + // Beim Verlassen der Seite einen noch offenen Ablauf freigeben. + useEffect(() => { + return () => { + const open = flowRef.current; + if (open) void cancelLoginFlow(open.flowId).catch(() => undefined); + }; + }, []); + + const preferBrowser = + error?.code === 'credentialsOrTwoFactor' || error?.code === 'useBrowserLogin'; + const primaryClass = 'btn btn-primary w-full justify-center'; + const secondaryClass = 'btn btn-secondary w-full justify-center'; + + return ( +
+
+
+ {host &&

{host}

} +

{t('title')}

+
+ + {expired && ( +

+ {t('expiredNotice')} +

+ )} + + {flow ? ( +
+

{t('waiting.instruction')}

+ + {t('waiting.open')} + +

+

+ +
+ ) : ( + <> +

{t('explanation')}

+
void submit(e)} className="space-y-4"> +
+ + setLoginName(e.target.value)} + className={INPUT_CLASS} + /> +
+
+ + setPassword(e.target.value)} + className={INPUT_CLASS} + /> +
+ {error && ( +

+ {errorText(error)} +

+ )} + +
+ + + +
+ +

{t('browserHint')}

+
+ + )} +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx index 32dc33f..7e58328 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx @@ -1,8 +1,20 @@ -import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { + act, + 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 { NextcloudFilesSettings, NextcloudFilesStatus } from '@/lib/nextcloud-files-api'; +import { + type NextcloudFilesAccount, + NextcloudFilesRequestError, + type NextcloudFilesSettings, + type NextcloudFilesStatus, +} from '@/lib/nextcloud-files-api'; import de from '@/messages/de.json'; import NextcloudFilesPage from './page'; @@ -18,6 +30,11 @@ const mockGetStatus = vi.fn(); const mockGetSettings = vi.fn(); const mockSaveSettings = vi.fn(); const mockTestSettings = vi.fn(); +const mockConnect = vi.fn(); +const mockStartFlow = vi.fn(); +const mockPollFlow = vi.fn(); +const mockCancelFlow = vi.fn(); +const mockDisconnect = vi.fn(); vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { const actual = await importOriginal(); @@ -27,6 +44,11 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { getNextcloudFilesSettings: (...args: unknown[]) => mockGetSettings(...args), saveNextcloudFilesSettings: (...args: unknown[]) => mockSaveSettings(...args), testNextcloudFilesSettings: (...args: unknown[]) => mockTestSettings(...args), + connectWithPassword: (...args: unknown[]) => mockConnect(...args), + startLoginFlow: (...args: unknown[]) => mockStartFlow(...args), + pollLoginFlow: (...args: unknown[]) => mockPollFlow(...args), + cancelLoginFlow: (...args: unknown[]) => mockCancelFlow(...args), + disconnectNextcloud: (...args: unknown[]) => mockDisconnect(...args), }; }); @@ -55,6 +77,11 @@ beforeEach(() => { mockGetSettings.mockReset().mockResolvedValue(settings()); mockSaveSettings.mockReset(); mockTestSettings.mockReset(); + mockConnect.mockReset(); + mockStartFlow.mockReset(); + mockPollFlow.mockReset(); + mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true }); + mockDisconnect.mockReset(); }); afterEach(() => cleanup()); @@ -196,3 +223,246 @@ describe('SettingsTab', () => { expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy(); }); }); + +// --- Aufgabe 2: Verbinden, Browser-Anmeldung, Abmelden ------------------------------------ + +function account(over: Partial = {}): NextcloudFilesAccount { + return { + connected: true, + expired: false, + status: 'ACTIVE', + ncUserId: 'anna', + displayName: 'Anna Müller', + connectedVia: 'PASSWORD', + connectedAt: '2026-10-08T10:00:00.000Z', + ...over, + }; +} + +const LOGIN_URL = 'http://cloud.example/index.php/login/v2/flow/abc'; + +describe('ConnectPanel', () => { + it('eingerichtet ohne Konto: Verbindungsbildschirm mit Feldern, Anmelden und "Im Browser anmelden"', async () => { + render(); + expect(await screen.findByLabelText('Benutzername oder E-Mail')).toBeTruthy(); + expect(screen.getByLabelText('Passwort')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Anmelden' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Im Browser anmelden' })).toBeTruthy(); + expect(screen.queryByText(/abgelaufen oder wurde in Nextcloud widerrufen/)).toBeNull(); + }); + + it('Anmelden ruft die API einmal mit dem getrimmten Namen auf, leert das Passwort und laedt den Stand neu', async () => { + mockConnect.mockResolvedValue(status({ account: account() })); + render(); + const name = await screen.findByLabelText('Benutzername oder E-Mail'); + const pass = screen.getByLabelText('Passwort') as HTMLInputElement; + fireEvent.change(name, { target: { value: ' anna ' } }); + fireEvent.change(pass, { target: { value: 'geheim' } }); + mockGetStatus.mockResolvedValue(status({ account: account() })); + fireEvent.click(screen.getByRole('button', { name: 'Anmelden' })); + await waitFor(() => expect(mockConnect).toHaveBeenCalledTimes(1)); + expect(mockConnect).toHaveBeenCalledWith({ loginName: 'anna', password: 'geheim' }); + expect(await screen.findByText('Angemeldet als Anna Müller')).toBeTruthy(); + expect(screen.queryByLabelText('Passwort')).toBeNull(); + }); + + it('nach einem Fehlschlag ist das Passwortfeld leer', async () => { + mockConnect.mockRejectedValue( + new NextcloudFilesRequestError(422, 'credentialsOrTwoFactor', 'x'), + ); + render(); + const pass = (await screen.findByLabelText('Passwort')) as HTMLInputElement; + fireEvent.change(screen.getByLabelText('Benutzername oder E-Mail'), { + target: { value: 'zoe' }, + }); + fireEvent.change(pass, { target: { value: 'falsch' } }); + fireEvent.click(screen.getByRole('button', { name: 'Anmelden' })); + await screen.findByRole('alert'); + expect((screen.getByLabelText('Passwort') as HTMLInputElement).value).toBe(''); + }); + + it('credentialsOrTwoFactor erklaert und macht "Im Browser anmelden" zur Hauptaktion', async () => { + mockConnect.mockRejectedValue( + new NextcloudFilesRequestError(422, 'credentialsOrTwoFactor', 'x'), + ); + render(); + const browser = await screen.findByRole('button', { name: 'Im Browser anmelden' }); + expect(browser.className).toContain('btn-secondary'); + fireEvent.change(screen.getByLabelText('Benutzername oder E-Mail'), { + target: { value: 'zoe' }, + }); + fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } }); + fireEvent.click(screen.getByRole('button', { name: 'Anmelden' })); + expect( + await screen.findByText(/Zwei-Faktor-Anmeldung/, { selector: 'p[role=alert]' }), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Im Browser anmelden' }).className).toContain( + 'btn-primary', + ); + }); + + it('tooManyAttempts zeigt die Wartezeit in Minuten, nextcloudLocked einen eigenen Text', async () => { + mockConnect.mockRejectedValueOnce( + new NextcloudFilesRequestError(429, 'tooManyAttempts', 'x', { retryAfterSeconds: 840 }), + ); + render(); + fireEvent.change(await screen.findByLabelText('Benutzername oder E-Mail'), { + target: { value: 'anna' }, + }); + fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } }); + fireEvent.click(screen.getByRole('button', { name: 'Anmelden' })); + expect( + await screen.findByText('Zu viele Fehlversuche. Bitte warten Sie 14 Minuten.'), + ).toBeTruthy(); + + mockConnect.mockRejectedValueOnce( + new NextcloudFilesRequestError(503, 'nextcloudLocked', 'x', { retryAfterSeconds: 600 }), + ); + fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } }); + fireEvent.click(screen.getByRole('button', { name: 'Anmelden' })); + expect( + await screen.findByText(/Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend/), + ).toBeTruthy(); + expect(screen.getByText(/10 Minuten erneut/)).toBeTruthy(); + }); + + it('abgelaufenes Konto: Hinweis ueber dem Formular', async () => { + mockGetStatus.mockResolvedValue( + status({ account: account({ connected: false, expired: true, status: 'EXPIRED' }) }), + ); + render(); + expect(await screen.findByText(/abgelaufen oder wurde in Nextcloud widerrufen/)).toBeTruthy(); + expect(screen.getByLabelText('Passwort')).toBeTruthy(); + }); +}); + +describe('Browser-Anmeldung', () => { + let openSpy: ReturnType; + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + mockStartFlow.mockResolvedValue({ + flowId: 'f1', + loginUrl: LOGIN_URL, + expiresAt: '2026-10-08T12:00:00.000Z', + }); + mockPollFlow.mockResolvedValue({ state: 'pending' }); + }); + afterEach(() => { + vi.useRealTimers(); + openSpy.mockRestore(); + }); + + async function startFlow() { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Im Browser anmelden' })); + return screen.findByRole('link', { name: 'Anmeldung bei Nextcloud öffnen' }); + } + + it('startet den Ablauf einmal und zeigt einen echten Verweis (target _blank), ohne window.open', async () => { + const link = (await startFlow()) as HTMLAnchorElement; + expect(mockStartFlow).toHaveBeenCalledTimes(1); + expect(link.getAttribute('href')).toBe(LOGIN_URL); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + expect(screen.getByText('Warten auf Bestätigung in Nextcloud …')).toBeTruthy(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('fragt alle 2000 ms ab und stoppt bei "connected" (Stand wird neu geladen)', async () => { + await startFlow(); + expect(mockPollFlow).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(mockPollFlow).toHaveBeenCalledTimes(1); + expect(mockPollFlow).toHaveBeenCalledWith('f1'); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(mockPollFlow).toHaveBeenCalledTimes(2); + + mockPollFlow.mockResolvedValue({ state: 'connected' }); + mockGetStatus.mockResolvedValue( + status({ account: account({ connectedVia: 'LOGIN_FLOW', displayName: 'Zwei Faktor' }) }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(await screen.findByText('Angemeldet als Zwei Faktor')).toBeTruthy(); + const after = mockPollFlow.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + expect(mockPollFlow.mock.calls.length).toBe(after); + }); + + it('Abbrechen ruft cancelLoginFlow auf und beendet die Abfrage', async () => { + await startFlow(); + fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' })); + await waitFor(() => expect(mockCancelFlow).toHaveBeenCalledWith('f1')); + expect(await screen.findByRole('button', { name: 'Im Browser anmelden' })).toBeTruthy(); + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + expect(mockPollFlow).not.toHaveBeenCalled(); + }); + + it('flowExpired (410) beendet die Abfrage mit Hinweis', async () => { + await startFlow(); + mockPollFlow.mockRejectedValue(new NextcloudFilesRequestError(410, 'flowExpired', 'x')); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect( + await screen.findByText('Die Anmeldung ist abgelaufen. Bitte starten Sie sie neu.'), + ).toBeTruthy(); + expect(screen.queryByRole('link', { name: 'Anmeldung bei Nextcloud öffnen' })).toBeNull(); + }); + + it('"failed" beendet die Abfrage und zeigt den Text', async () => { + await startFlow(); + mockPollFlow.mockResolvedValue({ + state: 'failed', + code: 'nextcloudUnavailable', + message: 'm', + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(await screen.findByText(/Nextcloud ist nicht erreichbar/)).toBeTruthy(); + }); +}); + +describe('AccountBar', () => { + beforeEach(() => { + mockGetStatus.mockResolvedValue(status({ account: account() })); + }); + + it('zeigt "Angemeldet als ..." und den Host', async () => { + render(); + expect(await screen.findByText('Angemeldet als Anna Müller')).toBeTruthy(); + expect(screen.getByText('cloud.example')).toBeTruthy(); + expect(screen.getByTestId('nextcloud-files-browser')).toBeTruthy(); + }); + + it('Abmelden fragt nach und ruft erst nach "Trennen" disconnectNextcloud auf', async () => { + mockDisconnect.mockResolvedValue({ disconnected: true }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Abmelden' })); + expect(mockDisconnect).not.toHaveBeenCalled(); + expect(screen.getByText(/Tessera vergisst den Zugang/)).toBeTruthy(); + mockGetStatus.mockResolvedValue(status()); + fireEvent.click(screen.getByRole('button', { name: 'Trennen' })); + await waitFor(() => expect(mockDisconnect).toHaveBeenCalledTimes(1)); + expect(await screen.findByLabelText('Passwort')).toBeTruthy(); + }); + + it('Abbrechen in der Rueckfrage trennt nichts', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Abmelden' })); + fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' })); + expect(mockDisconnect).not.toHaveBeenCalled(); + expect(screen.getByText('Angemeldet als Anna Müller')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx index 586b984..317f5fd 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx @@ -7,6 +7,8 @@ import { SettingsSection } from '@/components/control-center/settings-section'; import { PageHeader } from '@/components/layout/page-header'; import { getNextcloudFilesStatus, type NextcloudFilesStatus } from '@/lib/nextcloud-files-api'; import { useCanManageModule } from '@/lib/use-module-capability'; +import { AccountBar } from './components/AccountBar'; +import { ConnectPanel } from './components/ConnectPanel'; import { SettingsTab } from './components/SettingsTab'; type TabId = 'files' | 'settings'; @@ -15,9 +17,10 @@ type TabId = 'files' | 'settings'; * Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera. * Die Adresse der Nextcloud stellen Administratoren und Benutzer mit der * Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur - * den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Der - * Abschnitt `nextcloud-files-main` ist die Stelle, an der das Verbinden des - * eigenen Kontos und der Dateibrowser eingehaengt werden. + * den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Im + * Abschnitt `nextcloud-files-main` verbindet jeder Benutzer sein eigenes Konto + * (ConnectPanel); ist es verbunden, steht dort der Dateibereich + * (`nextcloud-files-browser`, den die Dateiansicht fuellt). */ export default function NextcloudFilesPage() { const t = useTranslations('nextcloudFiles'); @@ -40,13 +43,25 @@ export default function NextcloudFilesPage() { void reloadStatus(); }, [reloadStatus]); + const account = status?.account ?? null; + const connected = account?.connected === true; + const tabs: { id: TabId; label: string }[] = [{ id: 'files', label: t('tabs.files') }]; if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') }); const activeTab: TabId = tabs.some((x) => x.id === tab) ? tab : 'files'; return (
- + + ) : undefined + } + /> {statusError && (

{t('errors.loadStatus')} @@ -71,9 +86,19 @@ export default function NextcloudFilesPage() { )} {activeTab === 'files' && status?.configured && (

-

- {t('main.server', { host: status.host ?? '' })} -

+ {connected ? ( +
+

+ {t('main.server', { host: status.host ?? '' })} +

+
+ ) : ( + + )}
)} {activeTab === 'settings' && canManage && ( diff --git a/apps/web/src/lib/nextcloud-files-api.ts b/apps/web/src/lib/nextcloud-files-api.ts index fdbdabe..e491d9d 100644 --- a/apps/web/src/lib/nextcloud-files-api.ts +++ b/apps/web/src/lib/nextcloud-files-api.ts @@ -12,11 +12,25 @@ const BASE = '/modules/nextcloud-files'; export interface NextcloudFilesAccount { connected: boolean; expired: boolean; + status: 'ACTIVE' | 'EXPIRED'; ncUserId: string | null; displayName: string | null; connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null; + connectedAt: string | null; } +export interface NextcloudFilesFlowStart { + flowId: string; + /** Link zur Anmeldeseite der Nextcloud; der Benutzer oeffnet ihn mit eigenem Klick. */ + loginUrl: string; + expiresAt: string; +} + +export type NextcloudFilesFlowPoll = + | { state: 'pending' } + | { state: 'connected' } + | { state: 'failed'; code: string; message: string }; + export interface NextcloudFilesStatus { configured: boolean; serverUrl: string | null; @@ -118,3 +132,34 @@ export function saveNextcloudFilesSettings(input: { export function testNextcloudFilesSettings(baseUrl: string): Promise { return request('/settings/test', { method: 'POST', json: { baseUrl } }); } + +/** + * Verbinden mit Benutzername und Passwort. Das Passwort geht genau einmal an die + * API und wird nirgends zwischengespeichert; die Antwort enthaelt kein Geheimnis. + */ +export function connectWithPassword(input: { + loginName: string; + password: string; +}): Promise { + return request('/connect/password', { method: 'POST', json: input }); +} + +/** Startet die Browser-Anmeldung (Login Flow v2) fuer Konten mit Zwei-Faktor-Anmeldung. */ +export function startLoginFlow(): Promise { + return request('/connect/flow', { method: 'POST' }); +} + +export function pollLoginFlow(flowId: string): Promise { + return request(`/connect/flow/${encodeURIComponent(flowId)}`); +} + +export function cancelLoginFlow(flowId: string): Promise<{ cancelled: true }> { + return request<{ cancelled: true }>(`/connect/flow/${encodeURIComponent(flowId)}`, { + method: 'DELETE', + }); +} + +/** Trennt die Verbindung; die API widerruft den Zugang bei Nextcloud. */ +export function disconnectNextcloud(): Promise<{ disconnected: true }> { + return request<{ disconnected: true }>('/connect', { method: 'DELETE' }); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index a9d08ea..ee3b675 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -2337,6 +2337,46 @@ "main": { "server": "Nextcloud: {host}" }, + "connect": { + "title": "Mit Nextcloud verbinden", + "expiredNotice": "Ihre Verbindung zu Nextcloud ist abgelaufen oder wurde in Nextcloud widerrufen. Bitte melden Sie sich neu an.", + "explanation": "Melden Sie sich mit Ihrem Nextcloud-Konto an. Tessera speichert Ihr Passwort nicht, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit widerrufen können.", + "loginName": "Benutzername oder E-Mail", + "password": "Passwort", + "submit": "Anmelden", + "submitting": "Melde an …", + "or": "oder", + "browser": "Im Browser anmelden", + "starting": "Starte …", + "browserHint": "Für Konten mit Zwei-Faktor-Anmeldung", + "waiting": { + "instruction": "Öffnen Sie die Anmeldung bei Nextcloud, melden Sie sich dort an und bestätigen Sie mit „Zugriff gewähren“.", + "open": "Anmeldung bei Nextcloud öffnen", + "status": "Warten auf Bestätigung in Nextcloud …", + "cancel": "Abbrechen" + }, + "errors": { + "generic": "Die Anmeldung ist fehlgeschlagen. Bitte versuchen Sie es erneut.", + "credentialsOrTwoFactor": "Die Anmeldung hat nicht geklappt. Entweder stimmen Benutzername oder Passwort nicht, oder Ihr Konto nutzt die Zwei-Faktor-Anmeldung. Dann melden Sie sich bitte im Browser an.", + "useBrowserLogin": "Nextcloud erlaubt für dieses Konto keine Anmeldung mit Passwort. Bitte melden Sie sich im Browser an.", + "tooManyAttempts": "Zu viele Fehlversuche. Bitte warten Sie {minutes, plural, one {# Minute} other {# Minuten}}.", + "nextcloudLocked": "Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in {minutes, plural, one {# Minute} other {# Minuten}} erneut.", + "flowExpired": "Die Anmeldung ist abgelaufen. Bitte starten Sie sie neu.", + "nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.", + "tooManyFlows": "Zurzeit laufen zu viele Anmeldungen im Browser. Bitte versuchen Sie es gleich erneut." + } + }, + "account": { + "signedInAs": "Angemeldet als {name}", + "disconnect": "Abmelden", + "disconnectFailed": "Die Verbindung konnte nicht getrennt werden. Bitte versuchen Sie es erneut.", + "confirm": { + "title": "Verbindung zu Nextcloud trennen?", + "body": "Tessera vergisst den Zugang. Ihre Dateien in Nextcloud bleiben unverändert.", + "confirm": "Trennen", + "cancel": "Abbrechen" + } + }, "errors": { "request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.", "loadStatus": "Der Stand der Nextcloud-Anbindung konnte nicht geladen werden." diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 780168e..c22b389 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -2337,6 +2337,46 @@ "main": { "server": "Nextcloud: {host}" }, + "connect": { + "title": "Connect to Nextcloud", + "expiredNotice": "Your connection to Nextcloud has expired or was revoked in Nextcloud. Please sign in again.", + "explanation": "Sign in with your Nextcloud account. Tessera does not store your password; instead it has Nextcloud issue a separate access that you can revoke at any time.", + "loginName": "User name or email", + "password": "Password", + "submit": "Sign in", + "submitting": "Signing in …", + "or": "or", + "browser": "Sign in with the browser", + "starting": "Starting …", + "browserHint": "For accounts with two-factor authentication", + "waiting": { + "instruction": "Open the Nextcloud sign-in, log in there and confirm with “Grant access”.", + "open": "Open sign-in at Nextcloud", + "status": "Waiting for confirmation in Nextcloud …", + "cancel": "Cancel" + }, + "errors": { + "generic": "Signing in failed. Please try again.", + "credentialsOrTwoFactor": "Signing in did not work. Either the user name or password is wrong, or your account uses two-factor authentication. In that case, please sign in with the browser.", + "useBrowserLogin": "Nextcloud does not allow password sign-in for this account. Please sign in with the browser.", + "tooManyAttempts": "Too many failed attempts. Please wait {minutes, plural, one {# minute} other {# minutes}}.", + "nextcloudLocked": "Nextcloud is temporarily blocking requests from the Tessera server. Please try again in {minutes, plural, one {# minute} other {# minutes}}.", + "flowExpired": "The sign-in has expired. Please start it again.", + "nextcloudUnavailable": "Nextcloud is unreachable or did not answer in time.", + "tooManyFlows": "Too many browser sign-ins are running right now. Please try again in a moment." + } + }, + "account": { + "signedInAs": "Signed in as {name}", + "disconnect": "Sign out", + "disconnectFailed": "The connection could not be removed. Please try again.", + "confirm": { + "title": "Disconnect from Nextcloud?", + "body": "Tessera forgets the access. Your files in Nextcloud stay unchanged.", + "confirm": "Disconnect", + "cancel": "Cancel" + } + }, "errors": { "request": "The request failed. Please try again.", "loadStatus": "The state of the Nextcloud connection could not be loaded." diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index d4cd0c3..ea6c635 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -236,4 +236,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ 'dass', // quick-261008-mzu: Modul Dateien (Nextcloud) — korrektes Deutsch mit „ss“ 'zuverlässig', + // quick-261008-mzu (Aufgabe 2): Verbinden und Abmelden — korrektes Deutsch mit „ss“ + 'ausstellen', + 'vergisst', ]; diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 6206f96..cbfac3f 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -182,9 +182,9 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. | | handelsware-datev | 0 | 8 | 0 | **quick-261002-fm5:** neu, acht gebundene Rohtreffer über `tenantPrisma` in `handelsware-datev.service.ts` (`handelswareDatevConfig` 2, `handelswareKonto` 6), nachgemessen mit der Gate-Schleife: 0/8/0. Dazu fünf Zugriffe über den Transaktionsparameter `tx` von `withTenantTransaction` (`handelswareDatevConfig` 1, `handelswareKonto` 4), die diese einfache Rohtrefferzählung strukturell nicht sieht (siehe Hinweis zu `groups` oben) — die Bestandsaufnahme unten führt sie. Kein ungebundener Zugriff, kein Systemkontext. | | domains | 0 | 30 | 0 | **quick-261008-dts (Aufgabe 3):** +15 gebunden, nachgemessen mit der Gate-Schleife: 0/30/0. NEU `domains-orders.service.ts` mit 15 gebundenen Rohtreffern auf `domainsOrder` über `tenantPrisma`, je Methode ein eigener Klient (`createOrder` 3: `findFirst`, `updateMany` für den Entwurf, `create`; `submitOrder` 5: `findFirst` vor dem Anspruch, `updateMany` als atomarer Anspruch DRAFT→SUBMITTING mit Zählerprüfung, `findFirst` nach verlorenem Anspruch, `updateMany` fürs Ergebnis, `findFirst` zum Zurückgeben; `refreshOpenOrders` 3: `findMany` für hängende Ansprüche, `updateMany` auf UNKNOWN, `findMany` für offene Aufträge; `writeIf` 1: `updateMany` an den erwarteten Zustand gebunden; `cancelOrder` 2: `findFirst`, `updateMany`; `listOrders` 1: `findMany`). Jeder `where` trägt `tenantId`. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. Vorher: **quick-261008-dts (Aufgabe 2):** +13 gebunden, nachgemessen mit der Gate-Schleife: 0/15/0. NEU `domains-directory.service.ts` mit 13 gebundenen Rohtreffern über `tenantPrisma`, je Methode ein eigener Klient (`domainsCustomer` 8: `findMany` in `loadCustomers`, `findFirst` in `requireCustomer` und `createCustomer`, `create`, `updateMany` in `createCustomer` und `updateCustomer` (Markierung „eigene Firma“ lösen), `updateMany` fürs Ändern in `updateCustomer`, `deleteMany` in `deleteCustomer`; `domainsContactAssignment` 5: `findMany` in `loadAssignments`, `count` in `deleteCustomer`, zweimal `upsert` in `createContact`/`assignContacts`, `deleteMany` in `assignContacts`). Jeder `where` trägt `tenantId`, bei Zuordnungen zusätzlich das aktive System. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. Vorher: **quick-261008-dts (Aufgabe 1):** neu, zwei gebundene Rohtreffer über `tenantPrisma` in `domains-settings.service.ts` (`domainsConfig`: `loadRow` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. Die weiteren Dienste des Moduls (Kunden und Kontakte, Bestellungen) tragen ihre Zeilen, sobald sie gebaut sind. | -| nextcloud-files | 0 | 4 | 0 | **quick-261008-mzu (Aufgabe 1):** neu, vier gebundene Rohtreffer über je einen eigenen Klienten pro Methode in `nextcloud-files-settings.service.ts`, nachgemessen mit der Gate-Schleife: 0/4/0. `nextcloudFilesConfig` 2 (`loadConfig` `findUnique`, `saveSettings` `upsert`), `nextcloudFilesAccount` 2 (`countActiveAccounts` `count`, `saveSettings` `updateMany` auf ABGELAUFEN nach einem Adresswechsel). Nur mandantengebundene Verwaltungsvorgänge der Organisation; das persönliche Konto je Benutzer (Lesen, Verbinden, Trennen) kommt mit Aufgabe 2 und wird dann mit Mandant UND Benutzer gebunden. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. | +| nextcloud-files | 0 | 8 | 0 | **quick-261008-mzu (Aufgabe 2):** +4 gebunden, nachgemessen: 0/8/0. NEU `nextcloud-files-account.service.ts` mit vier gebundenen Rohtreffern auf `nextcloudFilesAccount`, je Methode ein eigener Klient mit Mandant UND Benutzer aus dem Token (`forTenant(this.prisma, tenantId, userId)`): `findAccount` `findFirst`, `upsertAccount` `upsert`, `deleteAccount` `deleteMany`, `markExpired` `updateMany`; jedes `where` trägt `tenantId` und `userId`. Die übrigen Beschreibungen gelten unverändert. **Vorher, quick-261008-mzu (Aufgabe 1):** neu, vier gebundene Rohtreffer über je einen eigenen Klienten pro Methode in `nextcloud-files-settings.service.ts`, nachgemessen mit der Gate-Schleife: 0/4/0. `nextcloudFilesConfig` 2 (`loadConfig` `findUnique`, `saveSettings` `upsert`), `nextcloudFilesAccount` 2 (`countActiveAccounts` `count`, `saveSettings` `updateMany` auf ABGELAUFEN nach einem Adresswechsel). Nur mandantengebundene Verwaltungsvorgänge der Organisation; das persönliche Konto je Benutzer (Lesen, Verbinden, Trennen) kommt mit Aufgabe 2 und wird dann mit Mandant UND Benutzer gebunden. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. | | nextcloud-status | 0 | 23 | 1 | **quick-261002-kxc (Aufgabe 3, Endstand):** 21→23 gebunden — `listRecentAlerts` in `nextcloud-alert.service.ts` liest die Abonnements des Aufrufers (`nextcloudAlertSubscription.findMany`, Klient mit Mandant UND Benutzer) und dazu die letzten Übergänge dieser Clouds (`nextcloudInstance.findMany`, nur skalare Felder, `where: { tenantId, id: { in } }`); System unverändert 1. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/23/1. Vorher: **quick-261002-kxc (Aufgabe 1):** 14→21 gebunden, System unverändert 1 — NEU `nextcloud-alert.service.ts` mit sieben gebundenen Rohtreffern (`subscribe`: `nextcloudInstance.findFirst`, `nextcloudAlertSubscription.upsert`; `unsubscribe`: `deleteMany`; `subscribedInstanceIds`: `findMany`; `evaluateAfterCheck`: `nextcloudInstance.updateMany`; `notifySubscribers`: `nextcloudAlertSubscription.findMany`, `user.findMany`), je Methode ein Klient; `nextcloud-status.service.ts` unverändert bei 14. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/21/1. Vorher: **quick-261002-k67 (Aufgabe 2, Endstand):** 4→14 gebunden, 0→1 System — Schreibwege und Planer: `updateInstance` (`findFirst`, `update`), `deleteInstance` (`findFirst`, `delete`), `uploadLogo` (`findFirst`, `update`), `getLogo` (`findFirst`), `removeLogo` (`findFirst`, `update`), `listInstanceIdsForTenant` (`findMany`) bringen zehn weitere gebundene Rohtreffer, je Methode ein Klient. Der eine System-Rohtreffer ist `loadAllInstancesForScheduler()` (`systemPrisma.nextcloudInstance.findMany`, nur `select: { id, tenantId }`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife: 0/14/1. Vorher: **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer in `nextcloud-status.service.ts` (`listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`). | -| **Summe** | **65** | **329** | **8** | **quick-261008-mzu (Aufgabe 1):** Gebunden +4 = `nextcloud-files` (`nextcloud-files-settings.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/329/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 3):** Gebunden +15 = `domains` (`domains-orders.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/325/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 2):** Gebunden +13 = `domains` (`domains-directory.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/310/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 1):** Gebunden +2 = `domains` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 65/297/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| **Summe** | **65** | **333** | **8** | **quick-261008-mzu (Aufgabe 2):** Gebunden +4 = `nextcloud-files` (`nextcloud-files-account.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/333/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-mzu (Aufgabe 1):** Gebunden +4 = `nextcloud-files` (`nextcloud-files-settings.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/329/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 3):** Gebunden +15 = `domains` (`domains-orders.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/325/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 2):** Gebunden +13 = `domains` (`domains-directory.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/310/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 1):** Gebunden +2 = `domains` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 65/297/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -436,6 +436,11 @@ und `/nextcloudFilesAccount`, beide `gebunden`): 106 Paare, davon 65 `muss-mandantengebunden`, 23 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. +quick-261008-mzu (Aufgabe 2): +1 `muss-mandantengebunden` (`nextcloud-files-account.service.ts`/`nextcloudFilesAccount`, +`gebunden`, Mandant UND Benutzer): +107 Paare, davon 66 `muss-mandantengebunden`, 23 `keine-mandantengebundene-tabelle`, 16 `beides`, +2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + quick-261002-kxc (Aufgabe 3): Paarzahl unverändert (96); `listRecentAlerts` nutzt dieselben Paare `nextcloud-alert.service.ts`/`nextcloudAlertSubscription` und `/nextcloudInstance`. @@ -905,8 +910,9 @@ werden. | apps/api/src/domains/domains-directory.service.ts | domainsContactAssignment | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 2):** neu — Zuordnung AutoDNS-Kontakt → Kunde. Das System (Demo/Live) ist Teil des Schlüssels (`@@unique([tenantId, environment, autodnsContactId])`), weil Kontakt-Nummern beider Systeme zwangsläufig kollidieren; jede Abfrage filtert auf das AKTIVE System. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000). Bewusst KEINE `system_read_policy`. Fünf Rohtreffer über `tenantPrisma` (`findMany`, `count`, zweimal `upsert`, `deleteMany`); der Fremdschlüssel auf den Kunden steht auf `Restrict`, ein Kunde mit Zuordnungen wird nicht gelöscht. Kein `include`/relationales `select`. | | apps/api/src/domains/domains-orders.service.ts | domainsOrder | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 3):** neu — Registrierungsaufträge des Moduls Domains (Entwurf, Anspruch, Ergebnis, Prüfspur mit Benutzer und Zeitpunkt der Bestätigung). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Organisationsdaten. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Stand wird beim Öffnen des Reiters abgeglichen. Fünfzehn Rohtreffer über `tenantPrisma`, je Methode ein eigener Klient; jeder `where` trägt `tenantId`, fremde Nummern enden als 404. Der Anspruch ist ein `updateMany` mit `status: 'DRAFT'` und dem aktiven System in der Bedingung; erst bei Zähler 1 geht `POST /domain` hinaus (Geld, höchstens einmal). Die Eindeutigkeit offener Aufträge je Domain und System sichert die Spalte `openKey` (`@@unique([tenantId, environment, openKey])`). Kein `include`/relationales `select`. | | apps/api/src/domains/domains-settings.service.ts | domainsConfig | muss-mandantengebunden | gebunden | **quick-261008-dts:** neu — Einstellungen des Moduls Domains (AutoDNS), eine Zeile je Organisation (Singleton): aktives System (Demo/Live), getrennte Zugänge je System mit AES-verschlüsseltem Passwort (CryptoService, nie an den Client zurückgegeben). Spalte für Standard-Nameserver mit quick-261008-h3t entfernt (Migration 20261008160000). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Einstellungen der Organisation, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Auftragsstatus wird beim Öffnen der Seite abgefragt. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`loadRow` `findUnique`, `saveSettings` `upsert`); `where` trägt `tenantId`. | -| apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts | nextcloudFilesAccount | muss-mandantengebunden | gebunden | **quick-261008-mzu (Aufgabe 1):** neu — das persönliche Nextcloud-Konto je Benutzer (verschlüsseltes App-Passwort). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` über Mandant UND Benutzer (Form aus Migration 20260929140000, `current_user_id() IS NULL OR "userId" = current_user_id()`, Migration 20261008180000). Bewusst KEINE `system_read_policy`: kein Hintergrunddienst liest über alle Mandanten. In DIESER Datei nur die beiden Verwaltungsvorgänge der Organisation über `const tenantPrisma`/`const expirer = forTenant(this.prisma, tenantId)` ohne Benutzer: `count` der aktiven Konten (für die Warnung vor einem Adresswechsel) und `updateMany` auf ABGELAUFEN für alle Konten der Organisation nach dem Adresswechsel; beide `where` tragen `tenantId`. Lesen und Schreiben des eigenen Kontos (mit Benutzer-Id aus dem Token) liegen in einer eigenen Datei und werden mit Aufgabe 2 eingetragen. Kein `include`/relationales `select`. | +| apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts | nextcloudFilesAccount | muss-mandantengebunden | gebunden | **quick-261008-mzu (Aufgabe 1):** neu — das persönliche Nextcloud-Konto je Benutzer (verschlüsseltes App-Passwort). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` über Mandant UND Benutzer (Form aus Migration 20260929140000, `current_user_id() IS NULL OR "userId" = current_user_id()`, Migration 20261008180000). Bewusst KEINE `system_read_policy`: kein Hintergrunddienst liest über alle Mandanten. In DIESER Datei nur die beiden Verwaltungsvorgänge der Organisation über `const tenantPrisma`/`const expirer = forTenant(this.prisma, tenantId)` ohne Benutzer: `count` der aktiven Konten (für die Warnung vor einem Adresswechsel) und `updateMany` auf ABGELAUFEN für alle Konten der Organisation nach dem Adresswechsel; beide `where` tragen `tenantId`. Lesen und Schreiben des eigenen Kontos (mit Benutzer-Id aus dem Token) liegen in `nextcloud-files-account.service.ts` (siehe dortige Zeile, Aufgabe 2). Kein `include`/relationales `select`. | | apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts | nextcloudFilesConfig | muss-mandantengebunden | gebunden | **quick-261008-mzu (Aufgabe 1):** neu — die EINE Nextcloud-Adresse der Organisation (Singleton, `tenantId` eindeutig). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008180000) — Einstellung der Organisation, keine persönlichen Daten. Bewusst KEINE `system_read_policy`. Zwei Rohtreffer über je einen eigenen Klienten (`loadConfig` `findUnique`, `saveSettings` `upsert`); `where` trägt `tenantId`. Kein `include`/relationales `select`. | +| apps/api/src/nextcloud-files/nextcloud-files-account.service.ts | nextcloudFilesAccount | muss-mandantengebunden | gebunden | **quick-261008-mzu (Aufgabe 2):** neu — das persönliche Nextcloud-Konto des angemeldeten Benutzers (Verbinden mit Passwort oder im Browser, Trennen, Sitzung für die Dateiaufrufe, Ablaufmarkierung). Vier Rohtreffer, je Methode ein eigener Klient `const tenantPrisma = forTenant(this.prisma, tenantId, userId)` mit Mandant UND Benutzer-Id aus dem Token (nie aus Body oder Query): `findAccount` `findFirst`, `upsertAccount` `upsert` (über `tenantId_userId`), `deleteAccount` `deleteMany`, `markExpired` `updateMany`. Jedes `where` trägt `tenantId` und `userId`; die Regel `tenant_isolation_policy` (Migration 20261008180000) lässt zusätzlich nur eigene Zeilen zu, ein anderer Benutzer derselben Organisation sieht die Zeile nicht (Spec: Benutzer B ohne Zeile bekommt `notConnected`, obwohl A verbunden ist). Kein `include`/relationales `select`, kein Systemkontext. Gespeichert wird nur das AES-verschlüsselte App-Passwort. | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | nextcloudAlertSubscription | muss-mandantengebunden | gebunden | **quick-261002-kxc:** neu — die persönliche Glocke „Benachrichtigen“ je Benutzer und Cloud. `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20261002170000, Form aus `Reminder`), bewusst OHNE `system_read_policy` — die Tabelle wird nie im Systemkontext gelesen. Jeder Zugriff läuft an den Mandanten gebunden: `subscribe`/`unsubscribe`/`subscribedInstanceIds` über `forTenant(this.prisma, tenantId, userId)` (Benutzer aus dem Token, nie aus dem Body), der Versand (`notifySubscribers`) über `forTenant(this.prisma, tenantId)` mit `where: { tenantId, instanceId }`; `listRecentAlerts` (Meldung in Tessera) liest nur die Abonnements des Aufrufers (`where: { tenantId, userId }`, Benutzer aus dem Token). | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | nextcloudInstance | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `subscribe` prüft per `findFirst` mit `where: { id, tenantId }`, dass die Cloud dem Mandanten gehört (sonst 404); `evaluateAfterCheck` beansprucht den Übergang per `updateMany` mit `where: { id, tenantId, alertState }` VOR dem Mailversand (nur `count === 1` meldet, mehrere API-Instanzen und Neustarts melden nie doppelt). Beides an den Mandanten gebunden, kein Systemkontext. | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | user | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `notifySubscribers` liest die Empfänger der Abonnements (`user.findMany` mit `where: { tenantId, id: { in } }`, nur skalare Felder E-Mail, Rolle, `isActive`), gebunden an den Mandanten der Cloud. Konto-Aktivität und Modulzugriff werden beim Senden erneut geprüft (nicht erst beim Einschalten der Glocke). |