Queue password checks and shorten busy retries (Refs #95)
CI / checks (push) Successful in 4m36s
CI / checks (push) Successful in 4m36s
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { scrypt } = vi.hoisted(() => ({ scrypt: vi.fn() }));
|
||||
vi.mock('node:crypto', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:crypto')>()),
|
||||
scrypt
|
||||
}));
|
||||
|
||||
let passwords: typeof import('../../src/lib/server/auth/passwords.js');
|
||||
let running: Array<(error: Error | null, result: Buffer) => void>;
|
||||
const key = Buffer.alloc(64, 7);
|
||||
const salt = 'a'.repeat(64);
|
||||
const encoded = `scrypt$131072$8$1$${salt}$${key.toString('hex')}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
running = [];
|
||||
scrypt.mockReset().mockImplementation((_password, _salt, _length, _parameters, callback) => {
|
||||
running.push(callback);
|
||||
});
|
||||
passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function finishNext(error: Error | null = null) {
|
||||
const callback = running.shift();
|
||||
if (!callback) throw new Error('No running scrypt operation');
|
||||
callback(error, key);
|
||||
}
|
||||
|
||||
describe('password work queue', () => {
|
||||
it('shares two slots across hashing and verification and serves waiting requests in order', async () => {
|
||||
const first = passwords.hashPassword('first password');
|
||||
const second = passwords.verifyPassword('second password', encoded);
|
||||
const third = passwords.verifyPassword('unknown account', null);
|
||||
const fourth = passwords.hashPassword('fourth password');
|
||||
await Promise.resolve();
|
||||
expect(scrypt).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(2);
|
||||
|
||||
finishNext();
|
||||
const fifth = passwords.verifyPassword('later arrival', encoded);
|
||||
await first;
|
||||
expect(scrypt).toHaveBeenCalledTimes(3);
|
||||
expect(scrypt.mock.calls[2]).toEqual([
|
||||
'unknown account',
|
||||
'0'.repeat(64),
|
||||
64,
|
||||
{ N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 },
|
||||
expect.any(Function)
|
||||
]);
|
||||
expect(running).toHaveLength(2);
|
||||
|
||||
finishNext();
|
||||
expect(await second).toBe(true);
|
||||
expect(scrypt.mock.calls[3][0]).toBe('fourth password');
|
||||
expect(running).toHaveLength(2);
|
||||
|
||||
finishNext();
|
||||
expect(await third).toBe(false);
|
||||
expect(scrypt.mock.calls[4][0]).toBe('later arrival');
|
||||
expect(running).toHaveLength(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
finishNext();
|
||||
finishNext();
|
||||
await expect(fourth).resolves.toMatch(/^scrypt\$/);
|
||||
await expect(fifth).resolves.toBe(true);
|
||||
expect(running).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('expires waiting requests after two seconds without deriving them or blocking later work', async () => {
|
||||
const first = passwords.hashPassword('first password');
|
||||
const second = passwords.hashPassword('second password');
|
||||
const expired = expect(
|
||||
passwords.verifyPassword('expired password', encoded)
|
||||
).rejects.toBeInstanceOf(passwords.PasswordServiceBusyError);
|
||||
await vi.advanceTimersByTimeAsync(1999);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
expect(scrypt).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expired;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
const later = passwords.verifyPassword('later password', encoded);
|
||||
finishNext();
|
||||
await first;
|
||||
expect(scrypt).toHaveBeenCalledTimes(3);
|
||||
expect(scrypt.mock.calls[2][0]).toBe('later password');
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
finishNext();
|
||||
finishNext();
|
||||
await second;
|
||||
await expect(later).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('bounds the waiting queue and recovers capacity after overload', async () => {
|
||||
const active = [
|
||||
passwords.hashPassword('first password'),
|
||||
passwords.hashPassword('second password')
|
||||
];
|
||||
const queued = Promise.allSettled(
|
||||
Array.from({ length: 16 }, (_, index) =>
|
||||
passwords.verifyPassword(`queued ${index}`, encoded)
|
||||
)
|
||||
);
|
||||
await expect(passwords.hashPassword('overflow password')).rejects.toBeInstanceOf(
|
||||
passwords.PasswordServiceBusyError
|
||||
);
|
||||
expect(scrypt).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(16);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
for (const result of await queued) {
|
||||
expect(result.status).toBe('rejected');
|
||||
if (result.status === 'rejected')
|
||||
expect(result.reason).toBeInstanceOf(passwords.PasswordServiceBusyError);
|
||||
}
|
||||
expect(scrypt).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
finishNext();
|
||||
finishNext();
|
||||
await Promise.all(active);
|
||||
|
||||
const recovered = [
|
||||
passwords.hashPassword('recovered password'),
|
||||
passwords.verifyPassword('recovered verification', encoded)
|
||||
];
|
||||
await Promise.resolve();
|
||||
expect(running).toHaveLength(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
finishNext();
|
||||
finishNext();
|
||||
await expect(Promise.all(recovered)).resolves.toEqual([expect.any(String), true]);
|
||||
});
|
||||
|
||||
it.each(['callback', 'throw'] as const)(
|
||||
'releases a slot after a scrypt %s failure',
|
||||
async (mode) => {
|
||||
const failure = new Error('scrypt failed');
|
||||
if (mode === 'throw')
|
||||
scrypt.mockImplementationOnce(() => {
|
||||
throw failure;
|
||||
});
|
||||
const failed = expect(passwords.hashPassword('failing password')).rejects.toBe(failure);
|
||||
const second = passwords.hashPassword('second password');
|
||||
const queued = passwords.verifyPassword('queued password', encoded);
|
||||
await Promise.resolve();
|
||||
if (mode === 'callback') finishNext(failure);
|
||||
await failed;
|
||||
expect(scrypt).toHaveBeenCalledTimes(3);
|
||||
expect(running).toHaveLength(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
finishNext();
|
||||
finishNext();
|
||||
await second;
|
||||
await expect(queued).resolves.toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user