- Locks no longer fail open: an unreachable backend throws LockUnavailableError and withLock skips the run (or opts into running unlocked, as template seeding does). The lnbits payment poller keeps polling through an outage, made safe by only sending confirmation emails when a row actually transitioned. - Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing counters stranded without a TTL. - Per-email login lockout moves to a Redis-backed store shared across replicas (in-memory fallback preserved), case-insensitive on email. - Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the server, force-close lingering SSE sockets, close Redis. - Active PING probe backs the health flag; /health reports last ping. SSE payment streams also poll the DB as a pub/sub-gap fallback. - Scale compose gains requirepass, maxmemory 256mb with noeviction, and an authenticated healthcheck; .env.example documents passwords, TLS (rediss://), and DB-index selection. - First tests in the repo: vitest + ioredis-mock covering the lock, rate limiter, and login lockout stores (27 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import RedisMock from 'ioredis-mock';
|
|
|
|
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
|
|
|
vi.mock('../redis.js', () => ({
|
|
isRedisEnabled: () => true,
|
|
getRedis: () => mocks.redis,
|
|
}));
|
|
|
|
import {
|
|
MemoryLoginLockout,
|
|
RedisLoginLockout,
|
|
MAX_LOGIN_ATTEMPTS,
|
|
} from './loginLockout.js';
|
|
|
|
describe('MemoryLoginLockout', () => {
|
|
it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => {
|
|
const lockout = new MemoryLoginLockout();
|
|
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
|
|
await lockout.recordFailure('user@example.com');
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
}
|
|
await lockout.recordFailure('user@example.com');
|
|
const status = await lockout.isLocked('user@example.com');
|
|
expect(status.locked).toBe(true);
|
|
expect(status.retryAfter).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('clear removes the lockout', async () => {
|
|
const lockout = new MemoryLoginLockout();
|
|
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
await lockout.recordFailure('user@example.com');
|
|
}
|
|
await lockout.clear('user@example.com');
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
});
|
|
|
|
it('treats emails case-insensitively', async () => {
|
|
const lockout = new MemoryLoginLockout();
|
|
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
await lockout.recordFailure('User@Example.com');
|
|
}
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
|
});
|
|
|
|
it('expires after the lockout window', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const lockout = new MemoryLoginLockout();
|
|
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
await lockout.recordFailure('user@example.com');
|
|
}
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
|
vi.advanceTimersByTime(16 * 60 * 1000);
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('RedisLoginLockout', () => {
|
|
beforeEach(async () => {
|
|
mocks.redis = new RedisMock();
|
|
// ioredis-mock shares data between instances by connection string.
|
|
await mocks.redis.flushall();
|
|
});
|
|
|
|
it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => {
|
|
const lockout = new RedisLoginLockout();
|
|
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
|
|
await lockout.recordFailure('user@example.com');
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
}
|
|
await lockout.recordFailure('user@example.com');
|
|
const status = await lockout.isLocked('user@example.com');
|
|
expect(status.locked).toBe(true);
|
|
expect(status.retryAfter).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('sets the lockout window TTL on the first failure', async () => {
|
|
const lockout = new RedisLoginLockout();
|
|
await lockout.recordFailure('user@example.com');
|
|
const ttl = await mocks.redis.pttl('lockout:user@example.com');
|
|
expect(ttl).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('clear removes the lockout', async () => {
|
|
const lockout = new RedisLoginLockout();
|
|
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
await lockout.recordFailure('user@example.com');
|
|
}
|
|
await lockout.clear('user@example.com');
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
});
|
|
|
|
it('treats emails case-insensitively', async () => {
|
|
const lockout = new RedisLoginLockout();
|
|
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
await lockout.recordFailure('User@Example.com');
|
|
}
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
|
});
|
|
|
|
it('fails open when the client is not initialized', async () => {
|
|
mocks.redis = null;
|
|
const lockout = new RedisLoginLockout();
|
|
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
});
|
|
|
|
it('fails open when the backend errors', async () => {
|
|
mocks.redis = {
|
|
get: () => Promise.reject(new Error('connection refused')),
|
|
pttl: () => Promise.reject(new Error('connection refused')),
|
|
del: () => Promise.reject(new Error('connection refused')),
|
|
lockoutRecordFailure: () => Promise.reject(new Error('connection refused')),
|
|
};
|
|
const lockout = new RedisLoginLockout();
|
|
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
|
|
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
await expect(lockout.clear('user@example.com')).resolves.toBeUndefined();
|
|
});
|
|
});
|