Files
core2026/app/gateway-api/test/authAttemptBudget.test.ts
T

132 lines
6.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthAttemptBudget, AuthBudgetError, InMemoryAuthCounterStore } from '../src/auth/attemptBudget.js';
describe('authentication attempt and resource budgets', () => {
afterEach(() => {
vi.useRealTimers();
});
it('shares a login subject across clients without exposing account or address in keys', async () => {
const store = new InMemoryAuthCounterStore();
const consume = vi.spyOn(store, 'consume');
const budget = new AuthAttemptBudget(store, 'fixture', 'fixture-secret');
for (let i = 0; i < 10; i++)
await budget.run('auth.login', `client-${i}`, 'login:fixture-user', async () => 'ok');
const work = vi.fn(async () => 'must not run');
await expect(budget.run('auth.login', 'another-client', 'login:fixture-user', work)).rejects.toMatchObject({
reason: 'limited',
retryAfterSeconds: 60,
});
expect(work).not.toHaveBeenCalled();
expect(JSON.stringify(consume.mock.calls)).not.toContain('fixture-user');
expect(JSON.stringify(consume.mock.calls)).not.toContain('client-');
await expect(budget.run('auth.login', 'another-client', 'login:other-user', async () => 'ok')).resolves.toBe(
'ok'
);
});
it('does not extend a blocked account window and admits it after expiry', async () => {
vi.useFakeTimers();
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
for (let i = 0; i < 10; i++) await budget.run('auth.login', 'client', 'login:user', async () => null);
await vi.advanceTimersByTimeAsync(59_000);
await expect(budget.run('auth.login', 'client', 'login:user', async () => null)).rejects.toMatchObject({
retryAfterSeconds: 1,
});
await vi.advanceTimersByTimeAsync(1000);
await expect(budget.run('auth.login', 'client', 'login:user', async () => 'recovered')).resolves.toBe(
'recovered'
);
});
it('limits public auth traffic per peer and across peers without charging rejected requests', async () => {
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-a', undefined, async () => null);
await expect(budget.run('auth.kakaoStart', 'peer-a', undefined, async () => null)).rejects.toMatchObject({
reason: 'limited',
});
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-b', undefined, async () => null);
await expect(budget.run('auth.kakaoStart', 'peer-c', undefined, async () => null)).rejects.toMatchObject({
reason: 'limited',
});
});
it('bounds expensive work and releases capacity after resolver failures', async () => {
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const work = vi.fn(async () => {
await pending;
throw new Error('fixture resolver failure');
});
const running = Array.from({ length: 4 }, () =>
budget.run('auth.login', 'client', undefined, work).catch(() => null)
);
await vi.waitFor(() => expect(work).toHaveBeenCalledTimes(4));
const rejected = vi.fn(async () => null);
await expect(budget.run('auth.registerLocal', 'client', undefined, rejected)).rejects.toBeInstanceOf(
AuthBudgetError
);
expect(rejected).not.toHaveBeenCalled();
release();
await Promise.all(running);
await expect(budget.run('auth.login', 'client', undefined, async () => 'ok')).resolves.toBe('ok');
});
it('bounds unfinished checks after caller timeouts and recovers only after they settle', async () => {
vi.useFakeTimers();
let release!: (retry: number) => void;
const waiting = new Promise<number>((resolve) => {
release = resolve;
});
const store = { consume: vi.fn(() => waiting) };
const budget = new AuthAttemptBudget(store, 'fixture', 'secret');
const work = vi.fn(async () => 'ok');
const requests = Array.from({ length: 32 }, () =>
budget.run('auth.passwordKey', 'client', undefined, work).catch((error: unknown) => error)
);
await vi.advanceTimersByTimeAsync(2001);
expect(
(await Promise.all(requests)).every(
(error) => error instanceof AuthBudgetError && error.reason === 'unavailable'
)
).toBe(true);
const actorLookup = vi.fn(async () => undefined);
await expect(budget.run('auth.login', 'client', actorLookup, work)).rejects.toMatchObject({
reason: 'unavailable',
});
expect(actorLookup).not.toHaveBeenCalled();
expect(work).not.toHaveBeenCalled();
expect(store.consume).toHaveBeenCalledTimes(32);
release(0);
await Promise.resolve();
await Promise.resolve();
await expect(budget.run('auth.login', 'client', undefined, work)).resolves.toBe('ok');
expect(work).toHaveBeenCalledTimes(1);
});
it('fails closed before credential work when the counter backend errors or stalls', async () => {
const work = vi.fn(async () => null);
const failing = new AuthAttemptBudget(
{
consume: async () => {
throw new Error('private-backend-error');
},
},
'fixture',
'secret'
);
await expect(failing.run('auth.login', 'client', undefined, work)).rejects.toMatchObject({
reason: 'unavailable',
});
vi.useFakeTimers();
const stalled = new AuthAttemptBudget({ consume: () => new Promise(() => {}) }, 'fixture', 'secret');
const result = stalled.run('auth.login', 'client', undefined, work).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(2001);
expect(await result).toMatchObject({ reason: 'unavailable' });
expect(work).not.toHaveBeenCalled();
});
});