78 lines
4.9 KiB
JavaScript
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');
|
|
});
|