The JWT lifetime is now pinned to SESSION_TTL_SECONDS (30 days)
inside issueSessionCookie, so JWT_EXPIRY had no effect after the
prior commits. Removing the field from Config and the env read in
loadConfig lets the TypeScript compiler verify no caller was still
relying on it.
Per the family invariant ('coherence across apps is a feature'),
the per-app override is intentionally gone — a deploy with stale
JWT_EXPIRY in .env will now silently use the 30-day family default
regardless of the value.
Test fixtures (Config literals in setup.ts, login-handler.test.ts,
lockout-service.test.ts) and config.test.ts assertions updated to
match the new shape.
127 lines
4.6 KiB
TypeScript
127 lines
4.6 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import type Database from 'better-sqlite3';
|
|
import { initDb } from '../../src/db/schema.ts';
|
|
import type { Config } from '../../src/config.ts';
|
|
import { createLockoutService } from '../../src/services/lockout.ts';
|
|
import { recordFailure as dbRecordFailure } from '../../src/db/login-attempts.ts';
|
|
|
|
function makeConfig(overrides: Partial<Config> = {}): Config {
|
|
return {
|
|
port: 0,
|
|
host: '127.0.0.1',
|
|
jwtSecret: 'x',
|
|
dbPath: ':memory:',
|
|
uploadDir: '/tmp',
|
|
logFile: '/tmp/x.log',
|
|
maxFileSize: 0,
|
|
baseUrl: '',
|
|
cookieSecure: false,
|
|
trustProxy: false,
|
|
lockoutThreshold: 3,
|
|
lockoutBaseSeconds: 10,
|
|
lockoutMaxSeconds: 80,
|
|
loginMinResponseMs: 0,
|
|
loginRateLimitMax: 0,
|
|
loginRateLimitWindowSeconds: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('lockout service', () => {
|
|
let db: Database.Database;
|
|
let nowMs: number;
|
|
const now = (): Date => new Date(nowMs);
|
|
|
|
beforeEach(() => {
|
|
db = initDb(':memory:');
|
|
nowMs = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
});
|
|
|
|
describe('check', () => {
|
|
it('returns not-locked when no row exists', () => {
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
expect(svc.check('alice')).toEqual({ locked: false });
|
|
});
|
|
|
|
it('returns not-locked when locked_until is in the past', () => {
|
|
const past = new Date(nowMs - 1000).toISOString();
|
|
dbRecordFailure(db, 'alice', past);
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
expect(svc.check('alice')).toEqual({ locked: false });
|
|
});
|
|
|
|
it('returns locked with retry-after seconds when locked_until is in the future', () => {
|
|
const future = new Date(nowMs + 30_000).toISOString();
|
|
dbRecordFailure(db, 'alice', future);
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
expect(svc.check('alice')).toEqual({ locked: true, retryAfterSeconds: 30 });
|
|
});
|
|
|
|
it('rounds up sub-second remainder so retry-after is never 0', () => {
|
|
const future = new Date(nowMs + 100).toISOString();
|
|
dbRecordFailure(db, 'alice', future);
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
expect(svc.check('alice').retryAfterSeconds).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('recordFailure', () => {
|
|
it('does not lock under threshold', () => {
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
const r1 = svc.recordFailure('alice');
|
|
expect(r1).toEqual({ locked: false, durationSeconds: 0, failedCount: 1 });
|
|
const r2 = svc.recordFailure('alice');
|
|
expect(r2).toEqual({ locked: false, durationSeconds: 0, failedCount: 2 });
|
|
});
|
|
|
|
it('locks with base duration at threshold', () => {
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
svc.recordFailure('alice');
|
|
svc.recordFailure('alice');
|
|
const r3 = svc.recordFailure('alice');
|
|
expect(r3.locked).toBe(true);
|
|
expect(r3.durationSeconds).toBe(10);
|
|
expect(r3.failedCount).toBe(3);
|
|
});
|
|
|
|
it('doubles duration past threshold', () => {
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
svc.recordFailure('alice'); // 1
|
|
svc.recordFailure('alice'); // 2
|
|
expect(svc.recordFailure('alice').durationSeconds).toBe(10); // 3 -> base
|
|
expect(svc.recordFailure('alice').durationSeconds).toBe(20); // 4
|
|
expect(svc.recordFailure('alice').durationSeconds).toBe(40); // 5
|
|
expect(svc.recordFailure('alice').durationSeconds).toBe(80); // 6 -> cap
|
|
expect(svc.recordFailure('alice').durationSeconds).toBe(80); // 7 -> still cap
|
|
});
|
|
|
|
it('persists locked_until reachable via check', () => {
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
svc.recordFailure('alice');
|
|
svc.recordFailure('alice');
|
|
svc.recordFailure('alice');
|
|
const status = svc.check('alice');
|
|
expect(status.locked).toBe(true);
|
|
expect(status.retryAfterSeconds).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('recordSuccess', () => {
|
|
it('clears the attempt row', () => {
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
svc.recordFailure('alice');
|
|
svc.recordFailure('alice');
|
|
svc.recordSuccess('alice');
|
|
// Next failure starts at 1, no lock
|
|
const r = svc.recordFailure('alice');
|
|
expect(r.failedCount).toBe(1);
|
|
expect(r.locked).toBe(false);
|
|
});
|
|
|
|
it('is a no-op for unknown username', () => {
|
|
const svc = createLockoutService({ db, config: makeConfig(), now });
|
|
expect(() => svc.recordSuccess('ghost')).not.toThrow();
|
|
});
|
|
});
|
|
});
|