Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts
T
schalli ddae9400a3 fix(nextcloud-files): WR-02/IN-04 Widerrufe auch nach Sperre, Netzfehler und abgebrochener Browser-Anmeldung
- voruebergehend gescheiterte Widerrufe (Aufrufsperre nach 429, Netz, Zeitablauf, Wartung,
  5xx) kommen in eine kleine Warteschlange im Arbeitsspeicher und werden nach dem Ende
  der Sperre bzw. nach einer Minute erneut versucht (hoechstens 200 Eintraege, 6 Versuche,
  nie geloggt)
- abgebrochene, ersetzte oder durch Adresswechsel verworfene Browser-Anmeldungen bleiben
  bis zum Ablauf als "abgebrochen" stehen; der Server fragt sie alle 10 s ab und widerruft
  ein doch noch ausgestelltes App-Passwort sofort; sie zaehlen nicht gegen die 200 offenen
- Specs fuer Sperre, Netzfehler, Aufgeben und den Login-Flow-Abbruch; Betriebshandbuch

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:36:32 +02:00

242 lines
9.4 KiB
TypeScript

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 — Reservierung (CR-01)', () => {
it('laufende Versuche zaehlen sofort: der 4. gleichzeitige Versuch von u1 ist 429', () => {
const { guard } = makeGuard();
const running = [0, 1, 2].map(() => guard.beginPasswordAttempt('u1'));
expect(running).toHaveLength(3);
const blocked = codeOf(() => guard.beginPasswordAttempt('u1'));
expect(blocked.status).toBe(429);
expect(blocked.body.code).toBe('tooManyAttempts');
});
it('release gibt den Platz frei, fail behaelt ihn', () => {
const { guard } = makeGuard();
const a = guard.beginPasswordAttempt('u1');
const b = guard.beginPasswordAttempt('u1');
const c = guard.beginPasswordAttempt('u1');
a.release();
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
b.fail();
c.fail();
// fail nach release (und umgekehrt) aendert nichts mehr
a.fail();
b.release();
const d = guard.beginPasswordAttempt('u1');
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBe(429);
d.release();
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
});
it('die Servergrenze gilt auch fuer gleichzeitige Versuche verschiedener Benutzer', () => {
const { guard } = makeGuard();
for (let i = 0; i < 8; i++) guard.beginPasswordAttempt(`u${i}`, 'http://a.example');
expect(codeOf(() => guard.beginPasswordAttempt('u9', 'http://a.example')).status).toBe(429);
});
it('recordSuccess laesst laufende Versuche desselben Benutzers stehen', () => {
const { guard } = makeGuard();
guard.recordFailure('u1');
guard.recordFailure('u1');
guard.beginPasswordAttempt('u1');
guard.recordSuccess('u1');
// eine abgeschlossene Fehlanmeldung weg, der laufende Versuch zaehlt noch: 1 von 3
guard.beginPasswordAttempt('u1');
guard.beginPasswordAttempt('u1');
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBe(429);
});
});
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('abgebrochene Ablaeufe sind fuer den Benutzer weg, werden aber bis zum Ablauf beobachtet (IN-04)', () => {
const { store, clock } = makeStore();
const a = store.create('t1', 'u1', 'http://c.example', 'tok-a');
store.cancel(a.flowId);
expect(store.lookup(a.flowId, 't1', 'u1')).toEqual({ state: 'missing' });
expect(store.hasCancelled()).toBe(true);
expect(store.cancelledDue(10_000).map((e) => e.pollToken)).toEqual(['tok-a']);
clock.t += FLOW_TTL_MS;
expect(store.cancelledDue(10_000)).toEqual([]);
expect(store.hasCancelled()).toBe(false);
});
it('abgebrochene Ablaeufe zaehlen nicht gegen die 200 offenen', () => {
const { store } = makeStore();
for (let i = 0; i < FLOW_MAX_TOTAL; i++) {
const e = store.create('t1', `u${i}`, 'http://c.example', `tok-${i}`);
store.cancel(e.flowId);
}
expect(() => store.create('t1', 'neu', 'http://c.example', 'tok')).not.toThrow();
});
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();
});
});