Profile recovery: Discord webhook as a reset-code channel

This commit is contained in:
Jonathan Sykes
2026-10-15 17:46:21 +08:00
parent ca47528581
commit 69704d3e1a
4 changed files with 52 additions and 26 deletions

View File

@@ -7,13 +7,15 @@ 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 { 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;
@@ -37,7 +39,11 @@ test('recovery contacts need the current secret and valid values', async () => {
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' });
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 () => {
@@ -62,7 +68,7 @@ test('requests are throttled, codes expire and run out of tries', async () => {
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' });
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/);
@@ -71,7 +77,8 @@ test('requests are throttled, codes expire and run out of tries', async () => {
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(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');
});