fix(nextcloud-files): CR-01 Anmeldebremse reserviert Versuche vor dem Nextcloud-Aufruf

- beginPasswordAttempt zaehlt einen Passwortversuch synchron und vor jedem await in
  Benutzer- und Servergrenze; release gibt den Platz bei Erfolg oder Nicht-Fehlversuch frei
- gleichzeitige Fehlversuche kommen nicht mehr an den Grenzen vorbei (3 je Benutzer, 8 je Server)
- Verbinden und Speichern laufen je Benutzer nacheinander (auch Browser-Anmeldung), damit
  zwei gleichzeitige Erfolge kein ungespeichertes, nie widerrufenes App-Passwort hinterlassen
- Specs mit gleichzeitigen Promise.all-Anmeldungen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:27:40 +02:00
parent 630398fe93
commit 5c2d327bef
4 changed files with 301 additions and 47 deletions
@@ -82,6 +82,53 @@ describe('NextcloudLoginGuard — Passwort-Fehlversuche', () => {
});
});
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();