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

@@ -1557,19 +1557,22 @@ async function recoveryContactsFlow() {
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Email${off('email')}</label> <label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Email${off('email')}</label>
<input id="recEmail" type="email" autocomplete="email" placeholder="you@example.com" value="${escapeHtml(cur.email || '')}" style="margin-bottom:12px" /> <input id="recEmail" type="email" autocomplete="email" placeholder="you@example.com" value="${escapeHtml(cur.email || '')}" style="margin-bottom:12px" />
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Discord user id${off('discord')}</label> <label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Discord user id${off('discord')}</label>
<input id="recDiscord" inputmode="numeric" autocomplete="off" placeholder="e.g. 123456789012345678" value="${escapeHtml(cur.discord || '')}" /> <input id="recDiscord" inputmode="numeric" autocomplete="off" placeholder="e.g. 123456789012345678" value="${escapeHtml(cur.discord || '')}" style="margin-bottom:12px" />
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Discord webhook</label>
<input id="recWebhook" type="url" autocomplete="off" spellcheck="false" placeholder="https://discord.com/api/webhooks/…" value="${escapeHtml(cur.webhook || '')}" />
<p style="margin:8px 0 0;color:var(--text-dim);font-size:12px;line-height:1.5"> <p style="margin:8px 0 0;color:var(--text-dim);font-size:12px;line-height:1.5">
Discord: Settings → Advanced → Developer Mode, then tap your name → Copy User ID. You must share a server with the YT Player bot for its message to reach you. Discord user id: Settings → Advanced → Developer Mode, then tap your name → Copy User ID; you must share a server with the YT Player bot.
Webhook: in a private channel of your own server, Edit Channel → Integrations → Webhooks → New Webhook → Copy Webhook URL. Anyone who can read that channel sees the code, so keep it private.
</p>`; </p>`;
showModal('🛟 Recovery contacts', body, [ showModal('🛟 Recovery contacts', body, [
{ label: 'Cancel', onClick: closeModal }, { label: 'Cancel', onClick: closeModal },
{ label: 'Save', primary: true, onClick: async () => { { label: 'Save', primary: true, onClick: async () => {
try { try {
const res = await fetch('/api/profile/recovery', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const res = await fetch('/api/profile/recovery', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, current: ProfileSecret.getFor(name), email: $('recEmail').value.trim(), discord: $('recDiscord').value.trim() }) }); body: JSON.stringify({ name, current: ProfileSecret.getFor(name), email: $('recEmail').value.trim(), discord: $('recDiscord').value.trim(), webhook: $('recWebhook').value.trim() }) });
const j = await res.json().catch(() => ({})); const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || 'could not save'); if (!res.ok || !j.ok) throw new Error(j.error || 'could not save');
closeModal(); toast(j.email || j.discord ? 'Recovery contacts saved ✓' : 'Recovery contacts removed'); closeModal(); toast(j.email || j.discord || j.webhook ? 'Recovery contacts saved ✓' : 'Recovery contacts removed');
} catch (e) { toast('⚠ ' + e.message); } } catch (e) { toast('⚠ ' + e.message); }
} }, } },
]); ]);
@@ -1581,7 +1584,7 @@ async function resetProfileSecretFlow(name) {
let info = null; let info = null;
try { info = await (await fetch(`/api/profile/info?name=${encodeURIComponent(name)}`)).json(); } catch { /* offline */ } try { info = await (await fetch(`/api/profile/info?name=${encodeURIComponent(name)}`)).json(); } catch { /* offline */ }
const rec = info && info.recovery; const rec = info && info.recovery;
if (!rec || (!rec.email && !rec.discord)) { if (!rec || (!rec.email && !rec.discord && !rec.webhook)) {
toast(`⚠ ${name} has no recovery email or Discord set — it opens only with its password, key or credentials file`, { duration: 6000 }); toast(`⚠ ${name} has no recovery email or Discord set — it opens only with its password, key or credentials file`, { duration: 6000 });
return null; return null;
} }
@@ -1591,6 +1594,7 @@ async function resetProfileSecretFlow(name) {
Send a reset code for <strong>${escapeHtml(name)}</strong>. It is valid for 15 minutes.</p>`; Send a reset code for <strong>${escapeHtml(name)}</strong>. It is valid for 15 minutes.</p>`;
showModal('🛟 Reset password', body, [ showModal('🛟 Reset password', body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } }, { label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
...(rec.webhook ? [{ label: 'Discord channel', primary: !rec.email && !rec.discord, onClick: () => { closeModal(); resolve('webhook'); } }] : []),
...(rec.discord ? [{ label: 'Discord DM', primary: !rec.email, onClick: () => { closeModal(); resolve('discord'); } }] : []), ...(rec.discord ? [{ label: 'Discord DM', primary: !rec.email, onClick: () => { closeModal(); resolve('discord'); } }] : []),
...(rec.email ? [{ label: `Email ${rec.emailHint || ''}`.trim(), primary: true, onClick: () => { closeModal(); resolve('email'); } }] : []), ...(rec.email ? [{ label: `Email ${rec.emailHint || ''}`.trim(), primary: true, onClick: () => { closeModal(); resolve('email'); } }] : []),
]); ]);

View File

@@ -316,7 +316,7 @@ export async function initDb() {
)`); )`);
// Optional profile protection: a password or generated key, stored only as a // Optional profile protection: a password or generated key, stored only as a
// slow salted hash (Bun.password / argon2id). NULL = the name alone opens it. // slow salted hash (Bun.password / argon2id). NULL = the name alone opens it.
for (const col of ['secret_hash TEXT', 'secret_kind TEXT', 'recovery_email TEXT', 'recovery_discord TEXT']) { for (const col of ['secret_hash TEXT', 'secret_kind TEXT', 'recovery_email TEXT', 'recovery_discord TEXT', 'recovery_webhook TEXT']) {
try { await db.execute(`ALTER TABLE profiles ADD COLUMN ${col}`); } catch { /* already there */ } try { await db.execute(`ALTER TABLE profiles ADD COLUMN ${col}`); } catch { /* already there */ }
} }
} }
@@ -763,13 +763,13 @@ export async function createProfile(name, dataJson) {
export async function getProfile(name) { export async function getProfile(name) {
const r = await db.execute({ const r = await db.execute({
sql: 'SELECT data, updated_at, secret_hash, secret_kind, recovery_email, recovery_discord FROM profiles WHERE name = ?', sql: 'SELECT data, updated_at, secret_hash, secret_kind, recovery_email, recovery_discord, recovery_webhook FROM profiles WHERE name = ?',
args: [name], args: [name],
}); });
const row = r.rows[0]; const row = r.rows[0];
if (!row) return null; if (!row) return null;
return { data: row.data, updatedAt: Number(row.updated_at), secretHash: row.secret_hash || null, secretKind: row.secret_kind || null, return { data: row.data, updatedAt: Number(row.updated_at), secretHash: row.secret_hash || null, secretKind: row.secret_kind || null,
recoveryEmail: row.recovery_email || null, recoveryDiscord: row.recovery_discord || null }; recoveryEmail: row.recovery_email || null, recoveryDiscord: row.recovery_discord || null, recoveryWebhook: row.recovery_webhook || null };
} }
export async function setProfileSecret(name, hash, kind) { export async function setProfileSecret(name, hash, kind) {

View File

@@ -2,20 +2,23 @@
// //
// A protected profile can store a recovery email and/or a Discord user id. // A protected profile can store a recovery email and/or a Discord user id.
// "Forgot password" sends a one-time code there (Gmail SMTP / Discord bot DM); // "Forgot password" sends a one-time code there (Gmail SMTP / Discord bot DM);
// the code plus a new password replaces the profile's secret. Both channels // the code plus a new password replaces the profile's secret. A Discord
// webhook URL the user pastes needs no server setup; the other two channels
// are off until their credentials are in the environment: // are off until their credentials are in the environment:
// SMTP_USER, SMTP_PASS (Gmail app password), optional SMTP_HOST/PORT/FROM // SMTP_USER, SMTP_PASS (Gmail app password), optional SMTP_HOST/PORT/FROM
// DISCORD_RELAY_URL + DISCORD_RELAY_TOKEN (discord-relay on the VPS), or DISCORD_BOT_TOKEN // DISCORD_RELAY_URL + DISCORD_RELAY_TOKEN (discord-relay on the VPS), or DISCORD_BOT_TOKEN
// //
// GET /api/profile/recovery?name= current contacts (needs the secret) // GET /api/profile/recovery?name= current contacts (needs the secret)
// POST /api/profile/recovery { name, current, email, discord } // POST /api/profile/recovery { name, current, email, discord, webhook }
// POST /api/profile/reset/request { name, via: 'email'|'discord' } // POST /api/profile/reset/request { name, via: 'email'|'discord'|'webhook' }
// POST /api/profile/reset/confirm { name, code, secret } // POST /api/profile/reset/confirm { name, code, secret }
import { createHash, randomInt, timingSafeEqual } from 'node:crypto'; import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
const CODE_TTL = 15 * 60_000, CODE_TRIES = 5; const CODE_TTL = 15 * 60_000, CODE_TRIES = 5;
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,}$/; const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,}$/;
const DISCORD_RE = /^\d{15,21}$/; const DISCORD_RE = /^\d{15,21}$/;
// Only Discord's own webhook endpoint, so a saved URL can never point the server elsewhere.
const WEBHOOK_RE = /^https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api(?:\/v\d+)?\/webhooks\/\d{15,21}\/[A-Za-z0-9_-]{30,100}$/;
const sha = (s) => createHash('sha256').update(s).digest(); const sha = (s) => createHash('sha256').update(s).digest();
const normCode = (s) => String(s || '').replace(/\D/g, ''); const normCode = (s) => String(s || '').replace(/\D/g, '');
@@ -25,8 +28,17 @@ export function maskEmail(email) {
return `${user.slice(0, 1)}${'•'.repeat(Math.max(2, Math.min(6, user.length - 1)))}@${domain}`; return `${user.slice(0, 1)}${'•'.repeat(Math.max(2, Math.min(6, user.length - 1)))}@${domain}`;
} }
export async function postWebhook(url, text) {
if (!WEBHOOK_RE.test(url)) throw new Error('not a Discord webhook URL');
const res = await fetch(`${url}?wait=true`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'YT Player', content: text, allowed_mentions: { parse: [] } }) });
if (res.status === 404 || res.status === 401) throw new Error('that Discord webhook no longer exists — save a new one under Recovery…');
if (!res.ok) throw new Error(`Discord refused the webhook message (${res.status})`);
}
export function createSenders(env = process.env) { export function createSenders(env = process.env) {
const senders = {}; const senders = { webhook: postWebhook };
if (env.SMTP_USER && env.SMTP_PASS) { if (env.SMTP_USER && env.SMTP_PASS) {
let transport = null; let transport = null;
senders.email = async (to, subject, text) => { senders.email = async (to, subject, text) => {
@@ -73,6 +85,7 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
const channels = (row) => ({ const channels = (row) => ({
email: !!(row?.recoveryEmail && senders.email), email: !!(row?.recoveryEmail && senders.email),
discord: !!(row?.recoveryDiscord && senders.discord), discord: !!(row?.recoveryDiscord && senders.discord),
webhook: !!(row?.recoveryWebhook && senders.webhook),
emailHint: senders.email ? maskEmail(row?.recoveryEmail) : null, emailHint: senders.email ? maskEmail(row?.recoveryEmail) : null,
}); });
const body = async (c) => { try { return await c.req.json(); } catch { return null; } }; const body = async (c) => { try { return await c.req.json(); } catch { return null; } };
@@ -83,8 +96,8 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
const row = name ? await getProfile(name) : null; const row = name ? await getProfile(name) : null;
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404); if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
const denied = await profileGate(c, name, row, c.req.header('x-profile-secret')); if (denied) return denied; const denied = await profileGate(c, name, row, c.req.header('x-profile-secret')); if (denied) return denied;
return c.json({ ok: true, email: row.recoveryEmail || '', discord: row.recoveryDiscord || '', return c.json({ ok: true, email: row.recoveryEmail || '', discord: row.recoveryDiscord || '', webhook: row.recoveryWebhook || '',
available: { email: !!senders.email, discord: !!senders.discord } }, 200, { 'Cache-Control': 'no-store' }); available: { email: !!senders.email, discord: !!senders.discord, webhook: !!senders.webhook } }, 200, { 'Cache-Control': 'no-store' });
}); });
app.post('/api/profile/recovery', async (c) => { app.post('/api/profile/recovery', async (c) => {
@@ -94,20 +107,21 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404); if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
if (!row.secretHash) return c.json({ ok: false, error: 'protect the profile first' }, 400); if (!row.secretHash) return c.json({ ok: false, error: 'protect the profile first' }, 400);
const denied = await profileGate(c, name, row, b.current || c.req.header('x-profile-secret')); if (denied) return denied; const denied = await profileGate(c, name, row, b.current || c.req.header('x-profile-secret')); if (denied) return denied;
const email = String(b.email || '').trim().toLowerCase(), discord = String(b.discord || '').trim(); const email = String(b.email || '').trim().toLowerCase(), discord = String(b.discord || '').trim(), webhook = String(b.webhook || '').trim();
if (email && !EMAIL_RE.test(email)) return c.json({ ok: false, error: 'that email address does not look right' }, 400); if (email && !EMAIL_RE.test(email)) return c.json({ ok: false, error: 'that email address does not look right' }, 400);
if (discord && !DISCORD_RE.test(discord)) return c.json({ ok: false, error: 'a Discord user id is 15–21 digits (Discord → Settings → Advanced → Developer Mode, then right-click your name → Copy User ID)' }, 400); if (discord && !DISCORD_RE.test(discord)) return c.json({ ok: false, error: 'a Discord user id is 15–21 digits (Discord → Settings → Advanced → Developer Mode, then right-click your name → Copy User ID)' }, 400);
await db.execute({ sql: 'UPDATE profiles SET recovery_email = ?, recovery_discord = ? WHERE name = ?', args: [email || null, discord || null, name] }); if (webhook && !WEBHOOK_RE.test(webhook)) return c.json({ ok: false, error: 'paste the full webhook URL from Discord (Channel settings → Integrations → Webhooks → Copy Webhook URL)' }, 400);
return c.json({ ok: true, email, discord }); await db.execute({ sql: 'UPDATE profiles SET recovery_email = ?, recovery_discord = ?, recovery_webhook = ? WHERE name = ?', args: [email || null, discord || null, webhook || null, name] });
return c.json({ ok: true, email, discord, webhook });
}); });
app.post('/api/profile/reset/request', async (c) => { app.post('/api/profile/reset/request', async (c) => {
await ready; await ready;
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400); const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
const name = nameOf(b.name), via = b.via === 'discord' ? 'discord' : 'email'; const name = nameOf(b.name), via = ['discord', 'webhook'].includes(b.via) ? b.via : 'email';
const row = name ? await getProfile(name) : null; const row = name ? await getProfile(name) : null;
if (!row || !row.secretHash) return c.json({ ok: false, error: 'profile not found or not protected' }, 404); if (!row || !row.secretHash) return c.json({ ok: false, error: 'profile not found or not protected' }, 404);
if (!channels(row)[via]) return c.json({ ok: false, error: `no ${via === 'email' ? 'recovery email' : 'Discord account'} is set up for this profile` }, 400); if (!channels(row)[via]) return c.json({ ok: false, error: `no ${via === 'email' ? 'recovery email' : via === 'webhook' ? 'Discord webhook' : 'Discord account'} is set up for this profile` }, 400);
const t = now(), recent = (sent.get(name) || []).filter((x) => t - x < 3600_000); const t = now(), recent = (sent.get(name) || []).filter((x) => t - x < 3600_000);
if (recent.length && t - recent[recent.length - 1] < 60_000) return c.json({ ok: false, error: 'a code was just sent — wait a minute before asking again' }, 429); if (recent.length && t - recent[recent.length - 1] < 60_000) return c.json({ ok: false, error: 'a code was just sent — wait a minute before asking again' }, 429);
if (recent.length >= 5) return c.json({ ok: false, error: 'too many reset codes this hour — try again later' }, 429); if (recent.length >= 5) return c.json({ ok: false, error: 'too many reset codes this hour — try again later' }, 429);
@@ -116,16 +130,17 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
const text = `Your YT Player reset code for the profile "${name}" is ${pretty}. It expires in 15 minutes.\n\nIf you did not ask for this, ignore it — the password has not changed.`; const text = `Your YT Player reset code for the profile "${name}" is ${pretty}. It expires in 15 minutes.\n\nIf you did not ask for this, ignore it — the password has not changed.`;
try { try {
if (via === 'email') await senders.email(row.recoveryEmail, `YT Player reset code: ${pretty}`, text); if (via === 'email') await senders.email(row.recoveryEmail, `YT Player reset code: ${pretty}`, text);
else if (via === 'webhook') await senders.webhook(row.recoveryWebhook, text);
else await senders.discord(row.recoveryDiscord, text); else await senders.discord(row.recoveryDiscord, text);
} catch (err) { } catch (err) {
console.error('[profile reset]', via, err.message); console.error('[profile reset]', via, err.message);
return c.json({ ok: false, error: via === 'discord' ? err.message : 'the email could not be sent — try again later' }, 502); return c.json({ ok: false, error: via === 'email' ? 'the email could not be sent — try again later' : err.message }, 502);
} }
recent.push(t); sent.set(name, recent); if (sent.size > 5000) sent.clear(); recent.push(t); sent.set(name, recent); if (sent.size > 5000) sent.clear();
await db.execute({ sql: `INSERT INTO profile_resets(name, code_hash, via, expires_at, attempts) VALUES(?,?,?,?,0) await db.execute({ sql: `INSERT INTO profile_resets(name, code_hash, via, expires_at, attempts) VALUES(?,?,?,?,0)
ON CONFLICT(name) DO UPDATE SET code_hash = excluded.code_hash, via = excluded.via, expires_at = excluded.expires_at, attempts = 0`, ON CONFLICT(name) DO UPDATE SET code_hash = excluded.code_hash, via = excluded.via, expires_at = excluded.expires_at, attempts = 0`,
args: [name, sha(code).toString('hex'), via, t + CODE_TTL] }); args: [name, sha(code).toString('hex'), via, t + CODE_TTL] });
return c.json({ ok: true, via, hint: via === 'email' ? maskEmail(row.recoveryEmail) : 'your Discord DMs' }); return c.json({ ok: true, via, hint: via === 'email' ? maskEmail(row.recoveryEmail) : via === 'webhook' ? 'your Discord channel' : 'your Discord DMs' });
}); });
app.post('/api/profile/reset/confirm', async (c) => { app.post('/api/profile/reset/confirm', async (c) => {

View File

@@ -7,13 +7,15 @@ import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-recovery-')); const root = mkdtempSync(join(tmpdir(), 'ytp-recovery-'));
process.env.DB_PATH = join(root, 'test.db'); process.env.DB_PATH = join(root, 'test.db');
const { initDb, db, createProfile, getProfile, setProfileSecret } = await import('./db.js'); 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 outbox = [];
const senders = { const senders = {
email: async (to, subject, text) => { outbox.push({ via: 'email', to, text }); }, email: async (to, subject, text) => { outbox.push({ via: 'email', to, text }); },
discord: async (to, text) => { outbox.push({ via: 'discord', 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. // Same contract as server.js profileGate: null when the secret opens the profile.
const profileGate = async (c, _name, row, provided) => { const profileGate = async (c, _name, row, provided) => {
if (!row.secretHash) return null; 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); 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' }); 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(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 () => { 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' }); 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); expect((await post('/api/profile/reset/confirm', { name: 'psalm', code: '11111111', secret: 'brand-new-pw2' })).status).toBe(429);
clock += 61_000; 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); const code = codeFrom(outbox.pop().text);
clock += 16 * 60_000; clock += 16 * 60_000;
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'brand-new-pw2' })).json.error).toMatch(/expired/); 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 () => { test('unprotected profiles and unconfigured channels are refused', async () => {
await createProfile('open', '{}'); await createProfile('open', '{}');
expect((await post('/api/profile/reset/request', { name: 'open', via: 'email' })).status).toBe(404); expect((await post('/api/profile/reset/request', { name: 'open', via: 'email' })).status).toBe(404);
expect(createSenders({})).toEqual({}); 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']); 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'); expect(maskEmail('a@b.co')).toBe('a••@b.co');
}); });