96 lines
5.8 KiB
TypeScript
96 lines
5.8 KiB
TypeScript
import { test, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import Fastify from 'fastify';
|
|
import { hashPassword, verifyPassword, normaliseEmail, validEmail, passwordProblem, limiter, authRoutes, ip } from './auth.ts';
|
|
import { pool } from './db.ts';
|
|
|
|
after(() => pool.end());
|
|
|
|
test('passwords: salted scrypt, verified in constant time, wrong ones rejected', async () => {
|
|
const h = await hashPassword('دیوان-غالب-123');
|
|
assert.match(h, /^scrypt\$32768\$8\$1\$[\w-]+\$[\w-]+$/);
|
|
assert.notEqual(h, await hashPassword('دیوان-غالب-123'), 'a new salt each time');
|
|
assert.equal(await verifyPassword('دیوان-غالب-123', h), true);
|
|
assert.equal(await verifyPassword('دیوان-غالب-124', h), false);
|
|
});
|
|
|
|
test('email and password checks', () => {
|
|
assert.equal(normaliseEmail(' Anas@Example.COM '), 'anas@example.com');
|
|
assert.ok(validEmail('a@b.pk') && !validEmail('a@b') && !validEmail('a b@c.pk'));
|
|
assert.equal(passwordProblem('1234567'), 'پاس ورڈ کم از کم ۸ حروف کا ہو');
|
|
assert.equal(passwordProblem('12345678'), null);
|
|
});
|
|
|
|
test('rate limiter: max per window, then resets', () => {
|
|
const allow = limiter(2, 1000);
|
|
assert.deepEqual([allow('ip', 0), allow('ip', 1), allow('ip', 2), allow('other', 2)], [true, true, false, true]);
|
|
assert.equal(allow('ip', 1001), true);
|
|
});
|
|
|
|
test('client address: x-client-ip is trusted only from the site, so a made-up one cannot dodge the limits', () => {
|
|
const req = (from: string, headers: Record<string, string>) => ({ ip: from, headers }) as any;
|
|
const saved = process.env.DIVAN_SITE_KEY;
|
|
try {
|
|
delete process.env.DIVAN_SITE_KEY; // local development: only this machine may pass an address
|
|
assert.equal(ip(req('127.0.0.1', { 'x-client-ip': '5.5.5.5' })), '5.5.5.5');
|
|
assert.equal(ip(req('203.0.113.9', { 'x-client-ip': '5.5.5.5' })), '203.0.113.9', 'a stranger is limited by their own address');
|
|
process.env.DIVAN_SITE_KEY = 'site-secret-1234';
|
|
assert.equal(ip(req('10.0.0.3', { 'x-client-ip': '5.5.5.5', 'x-site-key': 'site-secret-1234' })), '5.5.5.5', 'the site, with the key');
|
|
assert.equal(ip(req('10.0.0.3', { 'x-client-ip': '5.5.5.5', 'x-site-key': 'wrong' })), '10.0.0.3');
|
|
assert.equal(ip(req('127.0.0.1', { 'x-client-ip': '5.5.5.5' })), '127.0.0.1', 'with a key set, even this machine needs it');
|
|
} finally {
|
|
if (saved === undefined) delete process.env.DIVAN_SITE_KEY; else process.env.DIVAN_SITE_KEY = saved;
|
|
}
|
|
});
|
|
|
|
test('HTTP flow: sign up, sign in, wrong password, change password, delete', async () => {
|
|
const app = Fastify();
|
|
authRoutes(app);
|
|
const email = `test-${Date.now()}@divan.test`;
|
|
const call = (method: string, url: string, body?: object, token?: string) =>
|
|
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `t-${email}` } });
|
|
|
|
const up = await call('POST', '/api/auth/signup', { email, password: 'pass-word-1' });
|
|
assert.equal(up.statusCode, 200);
|
|
const t1 = up.json().token;
|
|
assert.equal((await call('POST', '/api/auth/signup', { email: email.toUpperCase(), password: 'pass-word-1' })).statusCode, 409, 'one account per email');
|
|
assert.equal((await call('GET', '/api/auth/me', undefined, t1)).json().user.email, email);
|
|
|
|
assert.equal((await call('POST', '/api/auth/signin', { email, password: 'wrong-pass' })).statusCode, 401);
|
|
const t2 = (await call('POST', '/api/auth/signin', { email, password: 'pass-word-1' })).json().token;
|
|
|
|
assert.equal((await call('POST', '/api/auth/password', { current: 'wrong-pass', next: 'pass-word-2' }, t2)).statusCode, 403);
|
|
assert.equal((await call('POST', '/api/auth/password', { current: 'pass-word-1', next: 'pass-word-2' }, t2)).statusCode, 200);
|
|
assert.equal((await call('GET', '/api/auth/me', undefined, t1)).statusCode, 401, 'other sessions signed out');
|
|
assert.equal((await call('GET', '/api/auth/me', undefined, t2)).statusCode, 200, 'this session kept');
|
|
|
|
// profile: Urdu name and bio, trimmed, control characters dropped, length-limited
|
|
const prof = (await call('POST', '/api/auth/profile', { full_name: ' مرزا اسد اللہ\u0007 خان ', bio: 'شاعر۔ '.repeat(300) }, t2)).json().user;
|
|
assert.equal(prof.full_name, 'مرزا اسد اللہ خان');
|
|
assert.equal(prof.bio.length, 1000);
|
|
assert.equal((await call('GET', '/api/auth/me', undefined, t2)).json().user.full_name, 'مرزا اسد اللہ خان');
|
|
assert.equal((await call('POST', '/api/auth/profile', { full_name: '', bio: '' }, t2)).json().user.full_name, '', 'cleared');
|
|
assert.equal((await call('POST', '/api/auth/profile', { full_name: 'x' })).statusCode, 401);
|
|
|
|
await call('POST', '/api/auth/signout', undefined, t2);
|
|
assert.equal((await call('GET', '/api/auth/me', undefined, t2)).statusCode, 401);
|
|
|
|
const t3 = (await call('POST', '/api/auth/signin', { email, password: 'pass-word-2' })).json().token;
|
|
assert.equal((await call('POST', '/api/auth/delete', { password: 'wrong' }, t3)).statusCode, 403);
|
|
assert.equal((await call('POST', '/api/auth/delete', { password: 'pass-word-2' }, t3)).statusCode, 200);
|
|
assert.equal((await pool.query('SELECT count(*)::int AS n FROM users WHERE email = $1', [email])).rows[0].n, 0);
|
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM sessions s LEFT JOIN users u ON u.id = s.user_id WHERE u.id IS NULL")).rows[0].n, 0);
|
|
await app.close();
|
|
});
|
|
|
|
test('sign-in is rate limited per email', async () => {
|
|
const app = Fastify();
|
|
authRoutes(app);
|
|
const email = `limit-${Date.now()}@divan.test`;
|
|
const codes = [];
|
|
for (let i = 0; i < 10; i++)
|
|
codes.push((await app.inject({ method: 'POST', url: '/api/auth/signin', payload: { email, password: 'x' }, headers: { 'x-client-ip': `ip-${i}` } })).statusCode);
|
|
assert.deepEqual(codes, [401, 401, 401, 401, 401, 401, 401, 401, 429, 429]);
|
|
await app.close();
|
|
});
|