splitwise / tests /unit /auth /auth.test.ts
assafvayner's picture
assafvayner HF Staff
fix(app): 503 on storage failures, validated currency, lock release on shutdown, per-IP login limit, unit-only npm test
e830ad4
Raw History Blame Contribute Delete
4.75 kB
import { describe, expect, it } from 'vitest';
import { hashPassword, verifyPassword } from '../../../src/lib/server/auth/password';
import { RateLimiter } from '../../../src/lib/server/auth/rate-limit';
import { createSessionToken, verifySessionToken } from '../../../src/lib/server/auth/session';
describe('password', () => {
it('hashes and verifies', async () => {
const hash = await hashPassword('correct horse');
expect(hash.startsWith('scrypt$')).toBe(true);
expect(await verifyPassword('correct horse', hash)).toBe(true);
expect(await verifyPassword('wrong', hash)).toBe(false);
});
it('salts: same password gives different hashes', async () => {
expect(await hashPassword('x')).not.toBe(await hashPassword('x'));
});
it('rejects malformed hashes without throwing', async () => {
expect(await verifyPassword('x', 'garbage')).toBe(false);
});
it('rejects stored hashes with out-of-bound scrypt params', async () => {
const placeholder = ['AAAAAAAAAAAAAAAAAAAAAA==', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='];
const bigP = ['scrypt', 32768, 8, 64, ...placeholder].join('$');
expect(await verifyPassword('whatever', bigP)).toBe(false);
const bigN = ['scrypt', 131072, 8, 1, ...placeholder].join('$');
expect(await verifyPassword('whatever', bigN)).toBe(false);
});
it('resolves 10 concurrent hashPassword calls (semaphore sanity)', async () => {
const results = await Promise.all(Array.from({ length: 10 }, (_, i) => hashPassword(`pw-${i}`)));
expect(results).toHaveLength(10);
for (const hash of results) expect(hash.startsWith('scrypt$')).toBe(true);
});
// 4 slots + a 64-deep wait queue: past that, callers are shed instead of parked forever.
it('sheds callers once the scrypt wait queue is full', async () => {
const settled = await Promise.allSettled(Array.from({ length: 70 }, (_, i) => hashPassword(`flood-${i}`)));
const rejected = settled.filter((r) => r.status === 'rejected');
expect(rejected.length).toBeGreaterThan(0);
for (const r of rejected) expect((r.reason as Error).message).toBe('Too many concurrent login attempts');
for (const r of settled.filter((r) => r.status === 'fulfilled')) {
expect((r as PromiseFulfilledResult<string>).value.startsWith('scrypt$')).toBe(true);
}
}, 60_000);
});
describe('session token', () => {
const secret = 'test-secret-with-enough-length-0123456789';
it('round-trips a payload', () => {
const token = createSessionToken({ uid: 'u_1', sv: 3 }, secret, new Date('2026-01-01T00:00:00Z'), 60);
expect(verifySessionToken(token, secret, new Date('2026-01-01T00:00:30Z'))).toEqual({ uid: 'u_1', sv: 3, exp: Date.parse('2026-01-01T00:01:00Z') });
});
it('rejects tampering, wrong secret, and expiry', () => {
const token = createSessionToken({ uid: 'u_1', sv: 3 }, secret, new Date('2026-01-01T00:00:00Z'), 60);
expect(verifySessionToken(token + 'x', secret, new Date('2026-01-01T00:00:00Z'))).toBeNull();
expect(verifySessionToken(token, 'other-secret', new Date('2026-01-01T00:00:00Z'))).toBeNull();
expect(verifySessionToken(token, secret, new Date('2026-01-01T00:01:01Z'))).toBeNull();
const [payload, sig] = token.split('.');
const forged = Buffer.from(JSON.stringify({ ...JSON.parse(Buffer.from(payload, 'base64url').toString()), uid: 'u_admin' })).toString('base64url');
expect(verifySessionToken(`${forged}.${sig}`, secret, new Date('2026-01-01T00:00:00Z'))).toBeNull();
expect(verifySessionToken('nonsense', secret, new Date())).toBeNull();
});
});
describe('RateLimiter', () => {
it('allows up to max attempts per window, then blocks until the window slides', () => {
let now = 0;
const rl = new RateLimiter(3, 60_000, () => now);
expect(rl.allow('bob')).toBe(true);
expect(rl.allow('bob')).toBe(true);
expect(rl.allow('bob')).toBe(true);
expect(rl.allow('bob')).toBe(false);
expect(rl.allow('alice')).toBe(true);
now = 60_001;
expect(rl.allow('bob')).toBe(true);
});
it('reset() clears a key so it is immediately allowed again', () => {
let now = 0;
const rl = new RateLimiter(5, 60_000, () => now);
expect(rl.allow('bob')).toBe(true);
expect(rl.allow('bob')).toBe(true);
expect(rl.allow('bob')).toBe(true);
expect(rl.allow('bob')).toBe(true);
expect(rl.allow('bob')).toBe(true);
expect(rl.allow('bob')).toBe(false);
rl.reset('bob');
expect(rl.allow('bob')).toBe(true);
});
it('evicts stale keys once the tracked-key cap is exceeded', () => {
let now = 0;
const rl = new RateLimiter(5, 1_000, () => now);
for (let i = 0; i < 10_001; i++) rl.allow(`key-${i}`);
expect(rl.size).toBe(10_001);
now = 2_000; // window has fully passed for every key inserted above
expect(rl.allow('new-key')).toBe(true);
expect(rl.size).toBe(1);
});
});