divan/api/src/auth.test.ts
Anas Rashid e409adc668 Accounts: email and password sign-up, sign-in/out, change password, delete account (#18)
- API (api/src/auth.ts): scrypt password hashes; random session tokens stored only as SHA-256;
  30-day sliding sessions; rate limits on sign-in (per IP and per email) and sign-up; changing the
  password signs out other devices; deleting the account removes its data.
- Site: /signup, /signin (returns to the page the reader came from), /account; header link; plain
  forms, no JavaScript needed. Session in an HTTP-only, SameSite=Lax cookie (Secure over HTTPS).
- CSRF: Astro's origin check, with the site's hostnames listed (SITE_HOSTS) so its own form posts
  pass and other sites' are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:53:51 +02:00

72 lines
4.0 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 } 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('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');
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();
});