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'); });