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

115 lines
8.2 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, postWebhook } = 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 }); },
webhook: async (to, text) => { outbox.push({ via: 'webhook', to, text }); },
};
const HOOK = 'https://discord.com/api/webhooks/123456789012345678/' + 'a'.repeat(68);
// 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, webhook: false, emailHint: 'l•••••@church.org' });
for (const bad of ['https://evil.example/api/webhooks/123456789012345678/' + 'a'.repeat(68), 'http://discord.com/api/webhooks/123456789012345678/' + 'a'.repeat(68), HOOK + '/../x'])
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', webhook: bad })).status).toBe(400);
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', email: 'leader@church.org', discord: '123456789012345678', webhook: HOOK })).json.webhook).toBe(HOOK);
expect(recovery.channels(await getProfile('psalm')).webhook).toBe(true);
});
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;
expect((await post('/api/profile/reset/request', { name: 'psalm', via: 'webhook' })).json.hint).toBe('your Discord channel');
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(Object.keys(createSenders({}))).toEqual(['webhook']);
expect(Object.keys(createSenders({ SMTP_USER: 'a', SMTP_PASS: 'b', DISCORD_RELAY_URL: 'http://x', DISCORD_RELAY_TOKEN: 't' })).sort()).toEqual(['discord', 'email', 'webhook']);
await expect(postWebhook('https://example.com/x', 'hi')).rejects.toThrow(/not a Discord webhook/);
expect(maskEmail('a@b.co')).toBe('a••@b.co');
});
test('admin can save Gmail settings and forward a bot token to the relay', async () => {
const calls = [];
const fakeRelay = async (url, init = {}) => {
calls.push({ url, method: init.method || 'GET', auth: init.headers.Authorization, body: init.body });
const ready = calls.some((x) => x.method === 'PUT');
return new Response(JSON.stringify(url.endsWith('/v1/bot-token') && !JSON.parse(init.body).token.includes('.')
? { ok: false, error: 'that does not look like a Discord bot token' }
: { ok: true, bot_set: ready, gateway: ready, bot: ready ? 'YT Player' : '' }));
};
const admin = new Hono();
const env = { DISCORD_RELAY_URL: 'http://relay:8791/', DISCORD_RELAY_TOKEN: 'r'.repeat(64) };
registerProfileRecoveryRoutes(admin, { db, getProfile, setProfileSecret, profileGate, env, fetchImpl: fakeRelay, adminAuth: async (_c, next) => next() });
const call = async (path, method, body) => { const r = await admin.request(path, { method, headers: { 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); return { status: r.status, json: await r.json() }; };
let s = await call('/api/admin/notify', 'GET');
expect(s.json.email).toMatchObject({ configured: false, source: null });
expect(s.json.discord).toMatchObject({ relay: true, botSet: false, ready: false });
expect((await call('/api/admin/notify', 'PUT', { smtpUser: 'sender@gmail.com' })).status).toBe(400);
s = await call('/api/admin/notify', 'PUT', { smtpUser: 'Sender@Gmail.com', smtpPass: 'abcd efgh ijkl mnop' });
expect(s.json.email).toMatchObject({ configured: true, source: 'admin', user: 'sender@gmail.com', passwordSet: true });
expect(JSON.stringify(s.json)).not.toContain('abcdefghijklmnop');
expect((await call('/api/admin/notify', 'PUT', { discordBotToken: 'junk' })).status).toBe(400);
s = await call('/api/admin/notify', 'PUT', { discordBotToken: 'MTIz.abcd.efgh' });
expect(s.json.discord).toMatchObject({ botSet: true, ready: true, bot: 'YT Player' });
expect(calls.every((x) => x.auth === `Bearer ${'r'.repeat(64)}` && x.url.startsWith('http://relay:8791/v1/'))).toBe(true);
expect((await call('/api/admin/notify/test', 'POST', { via: 'email', to: 'nope' })).json.error).toMatch(/email address/);
s = await call('/api/admin/notify', 'PUT', { smtpUser: '' });
expect(s.json.email.configured).toBe(false);
expect((await db.execute('SELECT COUNT(*) AS n FROM app_settings')).rows[0].n).toBe(0);
});