All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
Persists per-username failed-attempt counts and computed locked_until timestamps. Lockout service computes exponential-backoff durations (min(base * 2^(count-threshold), max)) with auto-unlock once locked_until passes. Successful login deletes the row, resetting the counter. Pure DB-keyed lockout — survives server restarts and shares state across both login routes (HTML and JSON) when wired in a later step.
73 lines
2.3 KiB
TypeScript
73 lines
2.3 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import type Database from 'better-sqlite3';
|
|
import { initDb } from '../../src/db/schema.ts';
|
|
import {
|
|
getLoginAttempt,
|
|
recordFailure,
|
|
resetLoginAttempts,
|
|
} from '../../src/db/login-attempts.ts';
|
|
|
|
describe('login-attempts db', () => {
|
|
let db: Database.Database;
|
|
|
|
beforeEach(() => {
|
|
db = initDb(':memory:');
|
|
});
|
|
|
|
it('returns undefined for an unknown username', () => {
|
|
expect(getLoginAttempt(db, 'ghost')).toBeUndefined();
|
|
});
|
|
|
|
it('inserts a row on first failure with count=1 and no lock', () => {
|
|
const row = recordFailure(db, 'alice', null);
|
|
expect(row.username).toBe('alice');
|
|
expect(row.failed_count).toBe(1);
|
|
expect(row.last_failed_at).toBeTruthy();
|
|
expect(row.locked_until).toBeNull();
|
|
});
|
|
|
|
it('increments failed_count on subsequent failures', () => {
|
|
recordFailure(db, 'alice', null);
|
|
recordFailure(db, 'alice', null);
|
|
const row = recordFailure(db, 'alice', null);
|
|
expect(row.failed_count).toBe(3);
|
|
});
|
|
|
|
it('persists locked_until when supplied', () => {
|
|
const lockedUntil = new Date(Date.now() + 30_000).toISOString();
|
|
const row = recordFailure(db, 'alice', lockedUntil);
|
|
expect(row.locked_until).toBe(lockedUntil);
|
|
});
|
|
|
|
it('updates locked_until on subsequent failures', () => {
|
|
recordFailure(db, 'alice', null);
|
|
const newLock = new Date(Date.now() + 60_000).toISOString();
|
|
const row = recordFailure(db, 'alice', newLock);
|
|
expect(row.failed_count).toBe(2);
|
|
expect(row.locked_until).toBe(newLock);
|
|
});
|
|
|
|
it('resetLoginAttempts deletes the row', () => {
|
|
recordFailure(db, 'alice', null);
|
|
resetLoginAttempts(db, 'alice');
|
|
expect(getLoginAttempt(db, 'alice')).toBeUndefined();
|
|
});
|
|
|
|
it('reset on a missing username is a no-op', () => {
|
|
expect(() => resetLoginAttempts(db, 'ghost')).not.toThrow();
|
|
});
|
|
|
|
it('tracks failures for non-existent users (no FK to users table)', () => {
|
|
const row = recordFailure(db, 'never-existed-user', null);
|
|
expect(row.failed_count).toBe(1);
|
|
});
|
|
|
|
it('getLoginAttempt returns the stored row', () => {
|
|
recordFailure(db, 'alice', null);
|
|
recordFailure(db, 'alice', null);
|
|
const row = getLoginAttempt(db, 'alice');
|
|
expect(row?.username).toBe('alice');
|
|
expect(row?.failed_count).toBe(2);
|
|
});
|
|
});
|