Files
ytplayer/server/profile-recovery.test.js

78 lines
4.9 KiB
JavaScript

import { expect, test, beforeAll } from 'bun:test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-recovery-'));
process.env.DB_PATH = join(root, 'test.db');
const { initDb, db, createProfile, getProfile, setProfileSecret } = await import('./db.js');
const { registerProfileRecoveryRoutes, maskEmail, createSenders } = await import('./profile-recovery.js');
const outbox = [];
const senders = {
email: async (to, subject, text) => { outbox.push({ via: 'email', to, text }); },
discord: async (to, text) => { outbox.push({ via: 'discord', to, text }); },
};
// Same contract as server.js profileGate: null when the secret opens the profile.
const profileGate = async (c, _name, row, provided) => {
if (!row.secretHash) return null;
return provided && await Bun.password.verify(String(provided), row.secretHash) ? null : c.json({ ok: false, needSecret: true }, 401);
};
let clock = 1_000_000;
const app = new Hono();
const recovery = registerProfileRecoveryRoutes(app, { db, getProfile, setProfileSecret, profileGate, senders, now: () => clock });
const post = async (path, body) => { const r = await app.request(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); return { status: r.status, json: await r.json() }; };
const codeFrom = (text) => text.match(/(\d{4})-(\d{4})/).slice(1).join('');
beforeAll(async () => {
await initDb();
await createProfile('psalm', '{}');
await setProfileSecret('psalm', await Bun.password.hash('old-password'), 'password');
});
test('recovery contacts need the current secret and valid values', async () => {
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'nope', email: 'a@b.co' })).status).toBe(401);
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', email: 'not-an-email' })).status).toBe(400);
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', discord: '12' })).status).toBe(400);
const ok = await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', email: 'Leader@Church.org', discord: '123456789012345678' });
expect(ok.json).toMatchObject({ ok: true, email: 'leader@church.org', discord: '123456789012345678' });
expect(recovery.channels(await getProfile('psalm'))).toEqual({ email: true, discord: true, emailHint: 'l•••••@church.org' });
});
test('a reset code sent by email replaces the password, once', async () => {
const sent = await post('/api/profile/reset/request', { name: 'psalm', via: 'email' });
expect(sent.json).toMatchObject({ ok: true, via: 'email' });
const mail = outbox.pop();
expect(mail.to).toBe('leader@church.org');
const code = codeFrom(mail.text);
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code: '00000000', secret: 'brand-new-pw' })).status).toBe(401);
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'short' })).status).toBe(400);
const done = await post('/api/profile/reset/confirm', { name: 'psalm', code: `${code.slice(0, 4)}-${code.slice(4)}`, secret: 'brand-new-pw' });
expect(done.json).toMatchObject({ ok: true, kind: 'password' });
expect(await Bun.password.verify('brand-new-pw', (await getProfile('psalm')).secretHash)).toBe(true);
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'another-one' })).status).toBe(400);
});
test('requests are throttled, codes expire and run out of tries', async () => {
clock += 3600_000;
expect((await post('/api/profile/reset/request', { name: 'psalm', via: 'discord' })).json.ok).toBe(true);
expect(outbox.pop()).toMatchObject({ via: 'discord', to: '123456789012345678' });
expect((await post('/api/profile/reset/request', { name: 'psalm', via: 'discord' })).status).toBe(429);
for (let i = 0; i < 5; i++) await post('/api/profile/reset/confirm', { name: 'psalm', code: '11111111', secret: 'brand-new-pw2' });
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code: '11111111', secret: 'brand-new-pw2' })).status).toBe(429);
clock += 61_000;
await post('/api/profile/reset/request', { name: 'psalm', via: 'email' });
const code = codeFrom(outbox.pop().text);
clock += 16 * 60_000;
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'brand-new-pw2' })).json.error).toMatch(/expired/);
});
test('unprotected profiles and unconfigured channels are refused', async () => {
await createProfile('open', '{}');
expect((await post('/api/profile/reset/request', { name: 'open', via: 'email' })).status).toBe(404);
expect(createSenders({})).toEqual({});
expect(Object.keys(createSenders({ SMTP_USER: 'a', SMTP_PASS: 'b', DISCORD_RELAY_URL: 'http://x', DISCORD_RELAY_TOKEN: 't' })).sort()).toEqual(['discord', 'email']);
expect(maskEmail('a@b.co')).toBe('a••@b.co');
});