build-container / build (push) Successful in 6m58s
- cypht/: official Cypht 2.13.0 on a SQLite file, served under /webmail/; workflow builds git.necloud.us/nate/hub-cypht - the hub passes /webmail/ through behind the passkey sign-in and signs in to Cypht for you (CYPHT_PASSWORD) - provider sign-in pages (Google, Microsoft) open in the whole window and return to the Mail tab; other links open in a new tab - Gmail / Outlook.com / Office365 OAuth settings (Microsoft permissions patched to the current ones) - Roundcube removed; README, compose and webmail tests updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
64 lines
6.6 KiB
JavaScript
64 lines
6.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// The /webmail/ pass-through to the webmail (Cypht): sign-in required, paths, a 401 reply with a streamed body, redirects, cookies, the sign-in helper.
|
|
// Uses a fake webmail. Run: node tools/webmail-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 = 'webmail-test-secret-'.repeat(2);
|
|
|
|
(async () => {
|
|
const seen = [];
|
|
const rc = http.createServer((rq, rs) => {
|
|
const body = []; rq.on('data', d => body.push(d)); rq.on('end', () => {
|
|
seen.push({ method: rq.method, url: rq.url, cookie: rq.headers.cookie || '', host: rq.headers.host, body: Buffer.concat(body).toString(), fproto: rq.headers['x-forwarded-proto'] });
|
|
if (rq.url.startsWith('/webmail/?_task=login') && rq.method === 'POST') { rs.writeHead(401, { 'content-type': 'text/html' }); return rs.end('<p>Login failed.</p>'); }
|
|
if (rq.url.startsWith('/webmail/?_task=login&_action=oauth')) { rs.writeHead(302, { location: 'https://login.example.test/authorize?client_id=1&state=2' }); return rs.end(); }
|
|
if (rq.url.startsWith('/webmail/?page=home&state=nux_authorization')) { rs.writeHead(303, { location: '/?page=servers', 'set-cookie': 'hm_session=oauth; path=/webmail/' }); return rs.end(); }
|
|
if (rq.url === '/webmail/go') { rs.writeHead(302, { location: 'http://127.0.0.1:' + rc.address().port + '/webmail/?_task=mail' }); return rs.end(); }
|
|
rs.writeHead(200, { 'content-type': 'text/html', 'set-cookie': ['hm_session=abc; path=/webmail/; HttpOnly', 'hm_auth=def; path=/webmail/'] }); rs.end('<h1>the webmail</h1>');
|
|
});
|
|
});
|
|
await new Promise(r => rc.listen(0, '127.0.0.1', r));
|
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webmail-test-')), port = 20000 + Math.floor(Math.random() * 20000);
|
|
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: ['mail'], handle: 'h', epoch: 1, disabled: false, created: 1 }] }));
|
|
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, WEBMAIL_BACKEND_URL: 'http://127.0.0.1:' + rc.address().port, CYPHT_USER: 'hub', CYPHT_PASSWORD: 'secret-pass', SEAFILE_URL: 'http://127.0.0.1:9' } });
|
|
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
|
|
await wait(1500);
|
|
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5, cookie = `hub_session=${exp}.${crypto.createHmac('sha256', SECRET).update('s' + exp).digest('base64url')}`;
|
|
const call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie: cookie + '; hm_session=keepme', ...(o.headers || {}) } });
|
|
|
|
let r = await fetch(base + '/webmail/', { redirect: 'manual' });
|
|
ok('without the hub sign-in, webmail goes to the sign-in page and the webmail is never contacted', r.status === 302 && r.headers.get('location') === '/login' && seen.length === 0, [r.status, seen.length]);
|
|
r = await call('/webmail/?_task=login');
|
|
ok('signed in: the page comes through, with the prefix removed', r.status === 200 && (await r.text()).includes('the webmail') && seen[0].url === '/webmail/?_task=login', seen[0]);
|
|
ok("the hub's own sign-in cookie is not passed on, but the webmail's is", !seen[0].cookie.includes('hub_session') && seen[0].cookie.includes('hm_session=keepme'), seen[0].cookie);
|
|
ok("the webmail's cookies come back (both of them)", (r.headers.getSetCookie() || []).length === 2, r.headers.getSetCookie());
|
|
r = await call('/webmail/?_task=login&_action=login', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '_user=a&_pass=b' });
|
|
ok('a wrong password (the webmail answers 401) shows its message instead of an error', r.status === 401 && (await r.text()).includes('Login failed'), r.status);
|
|
ok('the form data reached the webmail', seen[seen.length - 1].method === 'POST' && seen[seen.length - 1].body === '_user=a&_pass=b', seen[seen.length - 1]);
|
|
r = await call('/webmail/go');
|
|
ok('redirects stay under /webmail/', r.status === 302 && r.headers.get('location') === '/webmail/?_task=mail', r.headers.get('location'));
|
|
r = await call('/webmail/?_task=login&_action=oauth', { headers: { 'sec-fetch-dest': 'iframe' } }); let t0 = await r.text();
|
|
ok('a sign-in page elsewhere (Microsoft) is opened in the whole window, not inside the frame', r.status === 200 && t0.includes('top.location.href=\"https://login.example.test/authorize?client_id=1&state=2\"'), [r.status, t0.slice(0, 200)]);
|
|
r = await call('/webmail/?_task=login&_action=oauth', { headers: { 'sec-fetch-dest': 'document' } });
|
|
ok('opened on its own (new tab), the redirect to the sign-in page is passed on as it is', r.status === 302 && r.headers.get('location').startsWith('https://login.example.test/'), [r.status, r.headers.get('location')]);
|
|
r = await call('/webmail/?page=home&state=nux_authorization&code=1', { headers: { 'sec-fetch-dest': 'document' } });
|
|
ok('coming back from the sign-in page keeps the cookies and returns to the Mail tab', r.status === 302 && r.headers.get('location') === '/#/mail' && (r.headers.getSetCookie() || []).some(c => c.startsWith('hm_session=oauth')), [r.status, r.headers.get('location'), r.headers.getSetCookie()]);
|
|
r = await call('/api/webmail/login'); const lg = await r.json();
|
|
ok('the Mail tab can sign in to the webmail for you', lg.user === 'hub' && lg.pass === 'secret-pass', lg);
|
|
r = await fetch(base + '/api/webmail/login', { redirect: 'manual' });
|
|
ok('but only someone signed in to the hub', r.status === 302 || r.status === 401, r.status);
|
|
r = await call('/webmail'); ok('/webmail goes to /webmail/', r.status === 302 && r.headers.get('location') === '/webmail/', r.status);
|
|
r = await call('/api/config'); ok('the app is told where the webmail is', (await r.json()).webmail === '/webmail/');
|
|
rc.close(); await new Promise(r2 => setTimeout(r2, 100));
|
|
r = await call('/webmail/'); const t = await r.text();
|
|
ok('with the webmail down, a friendly page (not a crash)', r.status === 502 && t.includes('not reachable'), r.status);
|
|
r = await call('/api/config'); ok('and the server is still running', r.status === 200);
|
|
child.kill(); fs.rmSync(dataDir, { recursive: true, force: true });
|
|
console.log(failed ? '\n' + failed + ' check(s) failed' : '\nAll webmail checks passed'); process.exit(failed ? 1 : 0);
|
|
})();
|