File size: 4,748 Bytes
ef84ace
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c7b998
 
 
 
 
 
 
 
 
 
 
 
 
 
e830ad4
 
 
 
 
 
 
 
 
 
 
ef84ace
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c7b998
b11153a
 
 
 
 
 
 
 
 
 
 
 
 
1c7b998
 
 
 
 
 
 
 
 
ef84ace
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
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);
	});
});