Mail setup inside the app: admins save the Microsoft app ID, secret and expiry in Settings, with the redirect address to copy, an expiry warning, and plain-language Microsoft errors
build-container / build (push) Successful in 20m34s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 08:17:40 -04:00
1 parent 5c224521d3
commit 2d8d4f4d82
9 files changed
+175 -20

No files matched your search

+44 -1
View File
@@ -25,7 +25,8 @@ const SECRET = 'mail-test-secret-'.repeat(3);
ms.calls.push(rq.method + ' ' + decodeURIComponent(p + u.search));
if (p === '/consumers/oauth2/v2.0/token' && rq.method === 'POST') {
const f = new URLSearchParams(b);
if (f.get('client_id') !== 'test-client') return out(rs, 400, { error: 'invalid_client' });
if (!['test-client', 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'].includes(f.get('client_id'))) return out(rs, 400, { error: 'invalid_client', error_description: 'AADSTS700016: Application not found' });
if (ms.secret !== undefined && f.get('client_secret') !== ms.secret) return out(rs, 401, { error: 'invalid_client', error_description: ms.secret === 'EXPIRED' ? 'AADSTS7000222: expired' : 'AADSTS7000215: Invalid client secret provided' });
if (f.get('grant_type') === 'authorization_code') {
if (f.get('code') !== 'GOODCODE') return out(rs, 400, { error: 'invalid_grant', error_description: 'bad code' });
if (crypto.createHash('sha256').update(f.get('code_verifier') || '').digest('base64url') !== ms.challenge) return out(rs, 400, { error: 'invalid_grant', error_description: 'PKCE failed' });
@@ -144,8 +145,50 @@ const SECRET = 'mail-test-secret-'.repeat(3);
r = await post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false && !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')), r.json);
r = await call('unate', '/api/audit'); const evs = (r.json.events || []).map(e => e.event); ok('connecting, sending and disconnecting are in the activity log (without any message text)', ['mail connected', 'mail sent', 'mail disconnected'].every(e => evs.includes(e)) && !JSON.stringify(r.json).includes('not bold'), evs);
r = await call('unate', '/webmail/'); ok('the old /webmail/ pass-through is gone', r.status === 404 || r.status === 200 && !/Cypht/i.test(r.text), r.status);
// ================= admin: Microsoft app settings (environment version) =================
r = await call('unate', '/api/mail/admin'); ok('an admin sees the app settings: set from the environment, with the redirect address to register', r.status === 200 && r.json.source === 'env' && r.json.clientId === 'test-client' && r.json.redirect === base + '/api/mail/callback', r.json);
r = await call('uann', '/api/mail/admin'); ok('other people cannot see or change them', r.status === 403, r.status);
r = await post('unate', '/api/mail/admin', { clientId: '1a2b3c4d-1234-5678-9abc-def012345678' }); ok('with the environment in charge, saving is refused', r.status === 400 && /environment/.test(r.json.error), r.json);
r = await call('unate', '/api/config'); ok('the admin is told they can manage Mail setup', r.json.mailAdmin === '1', r.json);
ok('the server is still running', child.exitCode === null, log.slice(-300));
// ================= a hub with no environment settings: set up inside the app =================
child.kill(); await wait(300);
const port2 = port + 1, base2 = `http://localhost:${port2}`, dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mail2-'));
fs.copyFileSync(path.join(dir, 'users.json'), path.join(dir2, 'users.json'));
const env2 = { ...process.env, PORT: String(port2), DATA_DIR: dir2, COOKIE_SECRET: SECRET, MS_AUTH_URL: msBase, MS_GRAPH_URL: msBase + '/v1.0' }; delete env2.MS_CLIENT_ID; delete env2.MS_CLIENT_SECRET;
const child2 = spawn(process.execPath, ['server.js'], { cwd: root, env: env2 }); child2.stderr.on('data', () => {}); child2.stdout.on('data', () => {});
for (let i = 0; i < 60; i++) { try { await fetch(base2 + '/login'); break; } catch { await wait(100); } }
const call2 = async (uid, p, body, method) => { const rr = await fetch(base2 + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie: ck(uid), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); const tx = await rr.text(); let j; try { j = JSON.parse(tx); } catch { j = tx; } return { status: rr.status, json: j }; };
r = await call2('unate', '/api/config'); ok('without settings: Mail is off for everyone, but the admin can reach the setup', r.json.mail === '' && r.json.mailAdmin === '1', r.json);
r = await call2('uann', '/api/mail/status'); ok('and ordinary mail requests say it is not set up', r.status === 403, r.status);
r = await call2('unate', '/api/mail/admin'); ok('the setup page reports nothing saved yet', r.json.source === 'none' && r.json.hasSecret === false, r.json);
r = await call2('unate', '/api/mail/admin', { clientId: 'not-a-guid' }); ok('a malformed client ID is refused with an explanation', r.status === 400 && /36-character/.test(r.json.error), r.json);
const soon = new Date(Date.now() + 10 * 864e5).toISOString().slice(0, 10);
r = await call2('unate', '/api/mail/admin', { clientId: 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb', clientSecret: 'S3CRET', secretExpires: soon }); ok('the admin saves the app settings', r.status === 200 && r.json.source === 'app' && r.json.clientId === 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb' && r.json.hasSecret === true, r.json);
ok('the secret is never sent back', !JSON.stringify(r.json).includes('S3CRET') && !JSON.stringify((await call2('unate', '/api/mail/admin')).json).includes('S3CRET'));
ok('and is stored encrypted', !fs.readFileSync(path.join(dir2, 'mail-config.json'), 'utf8').includes('S3CRET'));
r = await call2('unate', '/api/mail/admin'); ok('days until the secret expires are reported (about 10)', r.json.days >= 9 && r.json.days <= 11, r.json);
r = await call2('unate', '/api/config'); ok('saving switches Mail on at once, with no restart', r.json.mail === '1', r.json);
ms.secret = 'OTHER'; { const c1 = await call2('unate', '/api/mail/connect'); const q1 = new URL(c1.json.url); ms.challenge = q1.searchParams.get('code_challenge');
const rr = await fetch(base2 + '/api/mail/callback?code=GOODCODE&state=' + q1.searchParams.get('state'), { redirect: 'manual' }); const tx = await rr.text();
ok('a wrong secret shows up when connecting, explained in plain words', rr.status === 400 && /secret is wrong/i.test(tx) && /Value/.test(tx), [rr.status, tx.slice(-200)]);
r = await call2('unate', '/api/mail/admin'); ok('and nothing is marked as proven', !r.json.verifiedAt, r.json); }
ms.secret = 'EXPIRED'; { const c1 = await call2('unate', '/api/mail/connect'); const q1 = new URL(c1.json.url); const rr = await fetch(base2 + '/api/mail/callback?code=GOODCODE&state=' + q1.searchParams.get('state'), { redirect: 'manual' }); ok('an expired secret is explained when connecting', /expired/i.test(await rr.text()), rr.status); }
ms.secret = 'S3CRET'; { const c1 = await call2('unate', '/api/mail/connect'); const q1 = new URL(c1.json.url); ms.challenge = q1.searchParams.get('code_challenge');
const rr = await fetch(base2 + '/api/mail/callback?code=GOODCODE&state=' + q1.searchParams.get('state'), { redirect: 'manual' }); await rr.text();
ok('with the right secret, connecting works with the settings saved in the app', rr.status === 302, rr.status);
r = await call2('unate', '/api/mail/admin'); ok('and the setup page now shows the app as proven (date of last good connection)', r.json.verifiedAt > 0, r.json);
r = await call2('unate', '/api/mail/status'); ok('the mailbox is connected', r.json.connected === true, r.json);
r = await call2('unate', '/api/mail/admin', { secretExpires: soon }); ok('changing only the expiry keeps it proven', r.json.verifiedAt > 0, r.json);
r = await call2('unate', '/api/mail/admin', { clientSecret: 'NEWSECRET' }); ok('but a new secret has to be proven again', !r.json.verifiedAt, r.json); }
r = await call2('unate', '/api/mail/admin/test', {}); ok('(the old test button endpoint is gone: Microsoft cannot check an app without a real sign-in)', r.status === 404 || r.status === 400, r.status);
r = await call2('uann', '/api/mail/admin', { clientId: 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb' }); ok('only an admin can save', r.status === 403, r.status);
r = await call2('unate', '/api/mail/admin', { clientId: '' }); r = await call2('unate', '/api/mail/admin', { clearSecret: true }); r = await call2('unate', '/api/config'); ok('removing the settings switches Mail off again', r.json.mail === '' && !fs.existsSync(path.join(dir2, 'mail-config.json')), r.json);
r = await call2('unate', '/api/audit'); ok('changes to the setup are in the activity log', (r.json.events || []).some(e => e.event === 'mail setup changed'), r.json);
child2.kill(); fs.rmSync(dir2, { recursive: true, force: true });
child.kill(); server.close(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll mail checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
+11
View File
@@ -47,6 +47,17 @@ const users = require('../users'), push = require('../push');
await push.tick(new Date(Date.now() + 60e3), true);
ok('and only once', sent.filter(s => s.title === 'Dentist').length === 1, sent);
// the Microsoft client secret for Mail is about to expire: admins are warned once per stage
const admin = users.create({ name: 'Boss', role: 'admin' }), ad = users.dirOf(admin.id);
fs.writeFileSync(path.join(ad, 'push.json'), JSON.stringify({ tz: TZ, tasks: false, events: false, subs: [{ sub: { endpoint: 'https://push.example/admin', keys: { p256dh: 'x', auth: 'y' } } }] }));
const in5 = new Date(Date.now() + 5 * 864e5).toISOString().slice(0, 10);
fs.writeFileSync(path.join(dir, 'mail-config.json'), JSON.stringify({ data: require('../vault')('nehub-mail').seal({ clientId: 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb', clientSecret: 's', secretExpires: in5 }) }));
sent.length = 0; await push.tick(new Date(), true);
ok('an admin is warned that the mail secret expires soon', sent.some(s => /Mail sign-in expires in [456] days/.test(s.title) && s.sub.endpoint.endsWith('/admin')), sent);
const n1 = sent.length; await push.tick(new Date(Date.now() + 60e3), true);
ok('and only once for that stage', sent.length === n1, sent.length);
ok('an ordinary person is not told about it', !sent.some(s => /Mail sign-in/.test(s.title) && !s.sub.endpoint.endsWith('/admin')), sent);
// a subscription the push service says is gone is dropped
dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] }));
await push.tick(new Date());