#!/usr/bin/env node
// Mail (Outlook.com through Microsoft Graph): connecting, token refresh, reading, sending, privacy between people.
// Uses a fake Microsoft sign-in/token service and a fake Graph. Run: node tools/mail-test.js
const { spawn } = require('child_process');
const http = require('http');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const wait = ms => new Promise(r => setTimeout(r, ms));
const SECRET = 'mail-test-secret-'.repeat(3);
(async () => {
// ---- fake Microsoft
const ms = { challenge: '', goodTokens: new Set(), refreshCalls: 0, revoked: false, tokenN: 0, calls: [], expiresIn: 3600 };
const body = rq => new Promise(r => { const c = []; rq.on('data', d => c.push(d)); rq.on('end', () => r(Buffer.concat(c).toString())); });
const out = (rs, code, o, h = {}) => { rs.writeHead(code, { 'content-type': 'application/json', ...h }); rs.end(o === undefined ? '' : JSON.stringify(o)); };
const folder = (id, name, unread, total) => ({ id: 'real-' + id, displayName: name, unreadItemCount: unread, totalItemCount: total });
const msgs = [
{ id: 'M1', subject: 'Hello there', from: { emailAddress: { name: 'Ann', address: 'ann@example.com' } }, receivedDateTime: '2026-10-22T10:00:00Z', isRead: false, hasAttachments: true, bodyPreview: 'Hi…', flag: { flagStatus: 'notFlagged' } },
{ id: 'M2', subject: 'Second', from: { emailAddress: { name: 'Bob', address: 'bob@example.com' } }, receivedDateTime: '2026-10-21T10:00:00Z', isRead: true, hasAttachments: false, bodyPreview: 'x', flag: { flagStatus: 'flagged' } },
];
const server = http.createServer(async (rq, rs) => {
const u = new URL(rq.url, 'http://x'), p = u.pathname, b = await body(rq);
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 (!['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' });
const at = 'AT' + (++ms.tokenN); ms.goodTokens = new Set([at]); return out(rs, 200, { access_token: at, refresh_token: 'RT1', expires_in: ms.expiresIn });
}
if (f.get('grant_type') === 'refresh_token') {
ms.refreshCalls++;
if (ms.revoked || !/^RT/.test(f.get('refresh_token'))) return out(rs, 400, { error: 'invalid_grant', error_description: 'revoked' });
const at = 'AT' + (++ms.tokenN); ms.goodTokens = new Set([at]); return out(rs, 200, { access_token: at, refresh_token: 'RT' + (ms.tokenN), expires_in: 3600 });
}
}
if (!p.startsWith('/v1.0/')) { rs.writeHead(404); return rs.end(); }
const tok = (rq.headers.authorization || '').replace('Bearer ', '');
if (!ms.goodTokens.has(tok)) return out(rs, 401, { error: { code: 'InvalidAuthenticationToken', message: 'expired' } });
const g = p.slice(5);
if (g === '/$batch' && rq.method === 'POST') { ms.batches = (ms.batches || 0) + 1; const reqs = JSON.parse(b).requests, self = `http://127.0.0.1:${server.address().port}/v1.0`; const responses = []; for (const q of reqs) { const r = await fetch(self + q.url, { method: q.method, headers: { authorization: rq.headers.authorization } }); responses.push({ id: q.id, status: r.status, body: await r.json().catch(() => ({})) }); } return out(rs, 200, { responses }); }
if (g === '/me') return out(rs, 200, { displayName: 'Nate', mail: 'nate@outlook.com' });
let m;
if ((m = /^\/me\/mailFolders\/(\w+)$/.exec(g))) { const f = { inbox: folder('inbox', 'Inbox', 3, 10), drafts: folder('drafts', 'Drafts', 0, 1), sentitems: folder('sentitems', 'Sent Items', 0, 4), archive: folder('archive', 'Archive', 0, 0), junkemail: folder('junkemail', 'Junk Email', 0, 0), deleteditems: folder('deleteditems', 'Deleted Items', 0, 2) }[m[1]]; return f ? out(rs, 200, f) : out(rs, 404, { error: { message: 'no folder' } }); }
if (g === '/me/mailFolders') return out(rs, 200, { value: [folder('inbox', 'Inbox', 3, 10), { id: 'CUSTOM1', displayName: 'Receipts', unreadItemCount: 1, totalItemCount: 5 }, { id: 'CUSTOM2', displayName: 'Outbox', unreadItemCount: 0, totalItemCount: 0 }] });
if ((m = /^\/me\/mailFolders\/([\w=-]+)\/messages$/.exec(g))) { const all = u.searchParams.get('$search') ? msgs.filter(x => x.subject.toLowerCase().includes(u.searchParams.get('$search').replace(/"/g, '').toLowerCase())) : msgs; return out(rs, 200, { value: all, ...(u.searchParams.get('$skip') === '0' && all.length >= 2 ? { '@odata.nextLink': 'x' } : {}) }); }
if (/\$expand=attachments\([^)]*contentId/.test(decodeURIComponent(u.search))) return out(rs, 400, { error: { message: "Parsing OData Select and Expand failed: Could not find a property named 'contentId' on type 'microsoft.graph.attachment'." } });
if ((m = /^\/me\/messages\/([\w=-]+)$/.exec(g))) {
if (rq.method === 'PATCH') { ms.patched = JSON.parse(b); return out(rs, 200, {}); }
if (rq.method === 'DELETE') { ms.deleted = m[1]; return out(rs, 204); }
const x = msgs.find(y => y.id === m[1]); if (!x) return out(rs, 404, { error: { message: 'gone' } });
return out(rs, 200, { ...x, toRecipients: [{ emailAddress: { name: 'Nate', address: 'nate@outlook.com' } }], ccRecipients: [], body: { contentType: 'html', content: '
Hi 
' },
attachments: [{ id: 'A1', name: 'report.pdf', contentType: 'application/pdf', size: 5, isInline: false }, { id: 'A2', name: 'logo.png', contentType: 'image/png', size: 4, isInline: true }] });
}
if ((m = /^\/me\/messages\/([\w=-]+)\/attachments\/(\w+)$/.exec(g))) return out(rs, 200, m[2] === 'A2' ? { contentBytes: Buffer.from('PNG!').toString('base64'), contentType: 'image/png', contentId: '', isInline: true } : { name: 'rep"ort\r\n.pdf', contentType: 'application/pdf', size: 5 });
if ((m = /^\/me\/messages\/([\w=-]+)\/attachments\/(\w+)\/\$value$/.exec(g))) { rs.writeHead(200, { 'content-type': 'application/pdf' }); return rs.end('%PDF-'); }
if ((m = /^\/me\/messages\/([\w=-]+)\/move$/.exec(g))) { ms.moved = [m[1], JSON.parse(b).destinationId]; return out(rs, 201, {}); }
if (g === '/me/sendMail') { ms.sent = JSON.parse(b); return out(rs, 202); }
if ((m = /^\/me\/messages\/([\w=-]+)\/(reply|replyAll|forward)$/.exec(g))) { ms.replied = [m[1], m[2], JSON.parse(b)]; return out(rs, 202); }
rs.writeHead(404); rs.end();
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const msBase = `http://127.0.0.1:${server.address().port}`;
// ---- the hub with two people: Nate (admin) and Sam (no Mail tab)
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mail-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`;
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [
{ id: 'unate', name: 'Nate', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
{ id: 'usam', name: 'Sam', role: 'user', tabs: ['tasks'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
{ id: 'uann', name: 'Ann', role: 'user', tabs: ['mail'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 }] }));
const exp = Date.now() + 864e5, ck = uid => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, MS_CLIENT_ID: 'test-client', MS_AUTH_URL: msBase, MS_GRAPH_URL: msBase + '/v1.0', PUBLIC_URL: '' } });
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const call = async (uid, p, o = {}) => { const r = await fetch(base + p, { redirect: 'manual', ...o, headers: { ...(uid ? { cookie: ck(uid) } : {}), 'content-type': 'application/json', ...(o.headers || {}) }, body: o.body && typeof o.body !== 'string' ? JSON.stringify(o.body) : o.body }); const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, json: j, headers: r.headers, text: t }; };
const post = (uid, p, b) => call(uid, p, { method: 'POST', body: b || {} });
// ================= access =================
let r = await call('uann', '/api/config'); ok('the app is told Mail is set up', r.json.mail === '1', r.json);
r = await call('usam', '/api/config'); ok('a person without the Mail tab is not told', r.json.mail === '', r.json);
r = await call('usam', '/api/mail/status'); ok('and cannot use mail at all', r.status === 403, r.status);
r = await call(null, '/api/mail/status'); ok('nor can someone who is not signed in', r.status === 401, r.status);
r = await call('unate', '/api/mail/status'); ok('before connecting: not connected', r.status === 200 && r.json.connected === false, r.json);
r = await call('unate', '/api/mail/messages'); ok('reading mail before connecting says to connect (409)', r.status === 409, r);
// ================= connecting =================
r = await call('unate', '/api/mail/connect'); const au = new URL(r.json.url);
ok('connect gives a Microsoft sign-in link for personal accounts with PKCE and mail permissions', au.pathname === '/consumers/oauth2/v2.0/authorize' && au.searchParams.get('client_id') === 'test-client' && au.searchParams.get('code_challenge_method') === 'S256' && /Mail\.ReadWrite/.test(au.searchParams.get('scope')) && /Mail\.Send/.test(au.searchParams.get('scope')) && au.searchParams.get('redirect_uri') === base + '/api/mail/callback', r.json);
ms.challenge = au.searchParams.get('code_challenge'); const state = au.searchParams.get('state');
r = await call(null, `/api/mail/callback?code=GOODCODE&state=WRONG`); ok('a callback with an unknown state is refused', r.status === 400, r.status);
r = await call(null, `/api/mail/callback?code=BAD&state=${state}`); ok('a rejected code does not connect anyone', r.status === 400 && /Could not connect/.test(r.text), r.status);
r = await call('unate', '/api/mail/connect'); const au2 = new URL(r.json.url); ms.challenge = au2.searchParams.get('code_challenge');
r = await call(null, `/api/mail/callback?code=GOODCODE&state=${au2.searchParams.get('state')}`); ok('the real callback connects and sends the person back to the Mail tab', r.status === 302 && r.headers.get('location') === '/#/mail', [r.status, r.headers.get('location')]);
r = await call(null, `/api/mail/callback?code=GOODCODE&state=${au2.searchParams.get('state')}`); ok('the sign-in link works only once', r.status === 400, r.status);
r = await call('unate', '/api/mail/status'); ok('now connected, with the address and the unread count', r.json.connected && r.json.address === 'nate@outlook.com' && r.json.unread === 3, r.json);
const raw = fs.readFileSync(path.join(dir, 'users', 'unate', 'mail-accounts.json'), 'utf8'); ok('the tokens are saved encrypted', !/AT1|RT1/.test(raw), raw.slice(0, 80));
r = await call('uann', '/api/mail/status'); ok("another person is not connected to Nate's mailbox", r.json.connected === false, r.json);
r = await call('uann', '/api/mail/messages'); ok("and cannot read it", r.status === 409, r.status);
// ================= reading =================
ms.batches = 0; r = await call('unate', '/api/mail/folders'); const names = (r.json || []).map(f => f.name);
ok('the folder list costs one batched call to Microsoft, not seven', ms.batches === 1, ms.batches);
ok('folders: the usual six first with unread counts, then your own; internal ones hidden', names.slice(0, 6).join() === 'Inbox,Drafts,Sent,Archive,Junk,Deleted' && names.includes('Receipts') && !names.includes('Outbox') && r.json[0].unread === 3 && names.filter(n => n === 'Inbox').length === 1, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('the inbox lists messages, newest first, with who, read state and attachment flag', r.status === 200 && r.json.items.length === 2 && r.json.items[0].from.address === 'ann@example.com' && r.json.items[0].read === false && r.json.items[0].attach === true && r.json.items[1].flagged === true, r.json);
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && /~M2$/.test(r.json.items[0].id), r.json);
r = await call('unate', '/api/mail/messages?folder=../me'); ok('a folder name cannot break out of the mail path', r.status === 400, r.status);
r = await call('unate', '/api/mail/message?id=M1'); const mm = r.json;
ok('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm);
ok('pictures that are part of the message are put inline (cid -> data)', /data:image\/png;base64,/.test(mm.html) && !/cid:logo1/.test(mm.html), mm.html);
ok('only real attachments are listed (not the inline picture)', mm.attachments.length === 1 && mm.attachments[0].name === 'report.pdf', mm.attachments);
r = await call('unate', '/api/mail/attachment?id=M1&aid=A1'); ok('an attachment downloads as a file, with a safe name', r.status === 200 && r.text === '%PDF-' && /attachment; filename="rep_ort__?\.pdf"/.test(r.headers.get('content-disposition')) && !/[\r\n]/.test(r.headers.get('content-disposition')), [r.status, r.headers.get('content-disposition')]);
// ================= changing things =================
await post('unate', '/api/mail/update', { id: 'M1', read: true }); ok('mark as read', ms.patched && ms.patched.isRead === true, ms.patched);
await post('unate', '/api/mail/update', { id: 'M1', flagged: true }); ok('flag', ms.patched.flag.flagStatus === 'flagged', ms.patched);
await post('unate', '/api/mail/move', { id: 'M1', to: 'archive' }); ok('archive moves to the Archive folder', ms.moved[0] === 'M1' && ms.moved[1] === 'archive', ms.moved);
await post('unate', '/api/mail/delete', { id: 'M2' }); ok('delete moves to Deleted Items', ms.moved[0] === 'M2' && ms.moved[1] === 'deleteditems', ms.moved);
await post('unate', '/api/mail/delete', { id: 'M2', permanent: true }); ok('and from Deleted Items it is removed for good', ms.deleted === 'M2', ms.deleted);
// ================= sending =================
r = await post('unate', '/api/mail/send', { to: 'bob@example.com, Ann ', cc: '', subject: 'Hi', body: 'Line 1\nnot bold' });
ok('send builds the message (several recipients, text turned into safe HTML)', r.status === 200 && ms.sent.message.toRecipients.length === 2 && ms.sent.message.toRecipients[1].emailAddress.address === 'ann@example.com' && /<b>not bold/.test(ms.sent.message.body.content) && ms.sent.saveToSentItems === true, ms.sent);
r = await post('unate', '/api/mail/send', { to: 'nobody', subject: 'x', body: 'y' }); ok('a bad address is refused with its name in the message', r.status === 400 && /nobody/.test(r.json.error), r.json);
r = await post('unate', '/api/mail/send', { to: '', subject: 'x', body: 'y' }); ok('no recipient is refused', r.status === 400, r.json);
r = await post('unate', '/api/mail/send', { to: 'a@b.co', subject: 'x', body: 'y', attachments: [{ name: 'big.bin', type: 'x/y', data: Buffer.alloc(3.5 * 1024 * 1024).toString('base64') }] }); ok('attachments over about 3 MB are refused', r.status === 400 && /3 MB/.test(r.json.error), r.json);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'replyAll', comment: 'Thanks' }); ok('reply all goes through the thread', r.status === 200 && ms.replied[1] === 'replyAll' && ms.replied[2].comment === 'Thanks', ms.replied);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'forward', comment: 'FYI', to: 'x@y.org' }); ok('forward needs a recipient and sends', r.status === 200 && ms.replied[2].toRecipients[0].emailAddress.address === 'x@y.org', ms.replied);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'forward', comment: 'FYI' }); ok('forward with no recipient is refused', r.status === 400, r.json);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'bogus' }); ok('an unknown action is refused', r.status === 400, r.json);
// ================= tokens =================
const refreshBefore = ms.refreshCalls;
ms.goodTokens = new Set(); // Microsoft no longer accepts the access token (expired early)
r = await call('unate', '/api/mail/folders'); ok('an expired access token is refreshed automatically and the request still works', r.status === 200 && ms.refreshCalls === refreshBefore + 1, [r.status, ms.refreshCalls]);
ms.revoked = true; ms.goodTokens = new Set();
r = await call('unate', '/api/mail/messages'); ok('if Microsoft revokes access, the person is told to connect again (409)', r.status === 409 && /Connect again/.test(r.json.error), r);
r = await call('unate', '/api/mail/status'); ok('and shown as not connected', r.json.connected === false, r.json);
// reconnect, then disconnect
ms.revoked = false; r = await call('unate', '/api/mail/connect'); const au3 = new URL(r.json.url); ms.challenge = au3.searchParams.get('code_challenge');
await call(null, `/api/mail/callback?code=GOODCODE&state=${au3.searchParams.get('state')}`); r = await call('unate', '/api/mail/status'); ok('connecting again works', r.json.connected === true, r.json);
r = await post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false, 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 the Microsoft app: Mail stays on (Gmail and iCloud need no setup) but there is no Outlook.com button, and the admin can reach the setup', r.json.mail === '1' && r.json.outlook === '' && r.json.mailAdmin === '1', r.json);
r = await call2('uann', '/api/mail/connect'); ok('and connecting Outlook.com says 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 the Outlook.com button on at once, with no restart', r.json.outlook === '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 the Outlook.com button off again', r.json.outlook === '' && !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); });