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(); }); });