Multiple people: an admin adds users and chooses which tabs each may use
build-container / build (push) Successful in 2m20s

- Accounts: the setup code (or an existing single-user install, migrated automatically) creates the admin;
  the admin adds people in Settings -> People and gives each a one-time invite code to enrol their own passkey
- Each person's tasks, NE Hub events and subscribe link, settings, calendar links, iCloud connection and Plex
  sign-in are private, stored in their own folder (/data/users/<id>/); Plex sign-in moved from browser cookies
  into that folder (encrypted), so it also follows them across devices
- Tab access is enforced by the server (403) and reflected in the app (nav, Home, Settings, search, /api/config
  hides addresses of tabs a person cannot use); browser storage is namespaced per person
- Admin tools: change tabs, invite a device, sign out everywhere (session epoch), turn off, make/remove admin,
  delete a person and their data; the last admin cannot be removed
- Sessions carry the person (uid.epoch.expiry.signature); old single-user cookies still work as the admin
- tools/users-test.js (83 checks: isolation, access limits, invites, sign-out/disable/delete, Plex and iCloud
  per person, migration) now runs in the Gitea workflow before the image is built

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 16:35:14 -04:00
1 parent 489ea0aaf0
commit f18d9dd7a9
16 files changed
+703 -165

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env node
// End-to-end test of accounts: admin setup, inviting people, private data, tab access, sign-out/disable/delete, and
// migration of a single-user install. Passkeys are made by a small software authenticator (no browser needed).
// Run: node tools/users-test.js
const { spawn } = require('child_process');
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 = 'users-test-secret-'.repeat(3);
// ---- software authenticator (WebAuthn "none" attestation, ES256)
const enc = b => Buffer.from(b).toString('base64url'), dec = s => Buffer.from(s, 'base64url'), sha = b => crypto.createHash('sha256').update(b).digest();
const head = (mt, n) => n < 24 ? Buffer.from([mt << 5 | n]) : n < 256 ? Buffer.from([mt << 5 | 24, n]) : Buffer.from([mt << 5 | 25, n >> 8, n & 255]);
const cb = v => typeof v === 'number' ? (v >= 0 ? head(0, v) : head(1, -1 - v)) : Buffer.isBuffer(v) ? Buffer.concat([head(2, v.length), v]) : typeof v === 'string' ? Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]) : v instanceof Map ? Buffer.concat([head(5, v.size), ...[...v].flatMap(([k, x]) => [cb(k), cb(x)])]) : null;
const device = () => { const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); return { jwk: publicKey.export({ format: 'jwk' }), priv: privateKey, id: crypto.randomBytes(32), n: 0 }; };
function start(dataDir, port, extraEnv = {}) {
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, ...extraEnv, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, HUB_SETUP_CODE: 'setup-code', AUTH_RATE_LIMIT: '10000', SEAFILE_URL: 'http://127.0.0.1:9' } });
const s = { child, log: '', alive: true }; child.on('exit', () => { s.alive = false; }); child.stdout.on('data', d => s.log += d); child.stderr.on('data', d => s.log += d);
return s;
}
async function ready(base) { for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); return; } catch { await wait(100); } } throw new Error('server did not start'); }
function client(base) {
let cookie = '';
const c = {
get cookie() { return cookie; }, set cookie(v) { cookie = v; },
async call(p, body, method) {
const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), redirect: 'manual', headers: { cookie, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
const set = r.headers.getSetCookie ? r.headers.getSetCookie() : []; const s = set.find(x => x.startsWith('hub_session=')); if (s) cookie = s.split(';')[0];
let json = {}; try { json = JSON.parse(await r.text()); } catch {}
return { status: r.status, json, set };
},
};
return c;
}
const RPID = 'localhost';
async function register(c, dev, extra, name, origin) {
let r = await c.call('/api/auth/register/options', extra); if (r.status !== 200) return r;
const { cid, options } = r.json;
const cd = JSON.stringify({ type: 'webauthn.create', challenge: options.challenge, origin, crossOrigin: false });
const cose = new Map([[1, 2], [3, -7], [-1, 1], [-2, dec(dev.jwk.x)], [-3, dec(dev.jwk.y)]]);
const ad = Buffer.concat([sha(RPID), Buffer.from([0x45]), Buffer.alloc(4), Buffer.alloc(16), Buffer.from([0, dev.id.length]), dev.id, cb(cose)]);
const att = cb(new Map([['fmt', 'none'], ['attStmt', new Map()], ['authData', ad]]));
return c.call('/api/auth/register/verify', { cid, name, response: { id: enc(dev.id), rawId: enc(dev.id), type: 'public-key', response: { clientDataJSON: enc(cd), attestationObject: enc(att), transports: ['internal'] }, clientExtensionResults: {} } });
}
async function login(c, dev, origin) {
const lo = await c.call('/api/auth/login/options', {}); if (lo.status !== 200) return lo;
const cd = JSON.stringify({ type: 'webauthn.get', challenge: lo.json.options.challenge, origin, crossOrigin: false });
dev.n++; const ad = Buffer.concat([sha(RPID), Buffer.from([0x05]), Buffer.from([0, 0, 0, dev.n])]);
const sig = crypto.sign('sha256', Buffer.concat([ad, sha(cd)]), dev.priv);
return c.call('/api/auth/login/verify', { cid: lo.json.cid, response: { id: enc(dev.id), rawId: enc(dev.id), type: 'public-key', response: { clientDataJSON: enc(cd), authenticatorData: enc(ad), signature: enc(sig) }, clientExtensionResults: {} } });
}
const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra });
(async () => {
// ================= Part 1: a fresh install with an admin and two people =================
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-users-')), port = 20000 + Math.floor(Math.random() * 20000);
const base = `http://localhost:${port}`, srv = start(dir, port); await ready(base);
const admin = client(base), sam = client(base), kim = client(base), anon = client(base);
const dA = device(), dS = device(), dS2 = device(), dK = device();
let r = await register(admin, dA, { setupCode: 'wrong' }, 'Admin laptop', base); ok('wrong setup code is refused', r.status === 403, r);
r = await register(admin, dA, { setupCode: 'setup-code' }, 'Admin laptop', base); ok('the setup code creates the first account and signs it in', r.status === 200 && /^hub_session=u[0-9a-f]+\.1\./.test(admin.cookie), [r.status, admin.cookie.slice(0, 30)]);
r = await admin.call('/api/me'); const me = r.json;
ok('the first account is the admin with every tab', me.role === 'admin' && ['files', 'music', 'calendar', 'tasks', 'verse', 'home', 'settings'].every(t => me.tabs.includes(t)), me);
r = await register(anon, device(), { setupCode: 'setup-code' }, 'x', base); ok('the setup code stops working once an account exists', r.status === 403, r);
await admin.call('/api/tasks', { list: 'local:inbox', fields: T('Admin private task') });
await admin.call('/api/cal/event', { cal: 'local', fields: { title: 'Admin private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
await admin.call('/api/settings', { key: 'mail_hints', value: { gmail: 'admin@gmail.com' } }, 'PUT');
// ---- add Sam with only Tasks and Calendar
r = await admin.call('/api/users', { name: 'Sam', tabs: ['tasks', 'calendar', 'search'] }); const samUser = r.json.user, samCode = r.json.code;
ok('admin adds a person and gets a one-time code', r.status === 200 && /^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(samCode) && samUser.tabsChosen.join() === 'tasks,calendar,search', r.json);
r = await admin.call('/api/users', { name: 'sam', tabs: [] }); ok('names must be unique (any case)', r.status === 400, r);
r = await admin.call('/api/users', { name: ' ', tabs: [] }); ok('a name is required', r.status === 400, r);
r = await register(sam, dS, { inviteCode: samCode }, 'Sam phone', base); ok('Sam enrols with the code and is signed in as Sam', r.status === 200 && sam.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, sam.cookie.slice(0, 24)]);
r = await register(client(base), device(), { inviteCode: samCode }, 'again', base); ok('the code works only once', r.status === 403, r);
r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json);
// ---- private data
r = await sam.call('/api/tasks?list=local:inbox'); ok("Sam starts with an empty task list (the admin's task is not visible)", r.status === 200 && r.json.length === 0, r.json);
await sam.call('/api/tasks', { list: 'local:inbox', fields: T('Sam private task') });
await sam.call('/api/cal/event', { cal: 'local', fields: { title: 'Sam private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
await sam.call('/api/settings', { key: 'mail_hints', value: { gmail: 'sam@gmail.com' } }, 'PUT');
const q = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
const at = (await admin.call('/api/tasks?list=local:inbox')).json.map(t => t.title), st = (await sam.call('/api/tasks?list=local:inbox')).json.map(t => t.title);
ok('tasks are private to each person', at.join() === 'Admin private task' && st.join() === 'Sam private task', [at, st]);
const ae = (await admin.call(q)).json.events.map(e => e.title), se = (await sam.call(q)).json.events.map(e => e.title);
ok('NE Hub calendar events are private to each person', ae.join() === 'Admin private event' && se.join() === 'Sam private event', [ae, se]);
ok('settings are private to each person', (await admin.call('/api/settings')).json.values.mail_hints.gmail === 'admin@gmail.com' && (await sam.call('/api/settings')).json.values.mail_hints.gmail === 'sam@gmail.com');
const fa = (await admin.call('/api/cal/feed')).json.url, fs_ = (await sam.call('/api/cal/feed')).json.url;
const fetchFeed = async u => { const x = await fetch(u.replace(/^https?:\/\/[^/]+/, base)); return { status: x.status, text: await x.text() }; };
const ta = await fetchFeed(fa), ts = await fetchFeed(fs_);
ok('each person has their own subscribe link, showing only their own events', fa !== fs_ && ta.text.includes('Admin private event') && !ta.text.includes('Sam private event') && ts.text.includes('Sam private event') && !ts.text.includes('Admin private event'), [ta.status, ts.status]);
r = await sam.call('/api/search?q=private'); ok('search shows only the searcher\'s own tasks and events', r.json.tasks.map(t => t.title).join() === 'Sam private task' && r.json.events.map(e => e.title).join() === 'Sam private event', r.json);
// ---- access limits are enforced by the server
for (const [p, label] of [['/sf/api2/ping/', 'Seafile (Files)'], ['/api/plex/status', 'Plex (Music)'], ['/plex/library/sections', 'Plex media']]) {
r = await sam.call(p); ok(`Sam is refused ${label}: 403`, r.status === 403, r.status);
}
r = await sam.call('/api/oo/config', {}); ok('Sam is refused document editing: 403', r.status === 403, r.status);
r = await sam.call('/api/users'); ok('Sam cannot list or manage people: 403', r.status === 403, r.status);
r = await sam.call('/api/users/' + me.id, { disabled: true }, 'PUT'); ok('Sam cannot change the admin: 403', r.status === 403, r.status);
r = await sam.call('/api/system'); ok('Sam cannot see server storage details: 403', r.status === 403, r.status);
r = await sam.call('/api/config'); ok('addresses of unavailable tabs are not revealed to Sam', r.json.seafile === '' && r.json.onlyoffice === '' && r.json.audiobooks === '' && r.json.verse === '' && r.json.podcast === '', r.json);
r = await admin.call('/api/config'); ok('the admin gets every address', r.json.seafile.startsWith('http') && r.json.audiobooks.startsWith('http'), r.json);
r = await sam.call('/api/tasks/lists'); ok('Sam can use the tabs he was given', r.status === 200);
// ---- change someone's access
r = await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'calendar', 'search', 'music'] }, 'PUT'); ok('admin adds Music for Sam', r.status === 200 && r.json.user.tabs.includes('music'), r.json);
r = await sam.call('/api/plex/status'); ok('Sam can now use Music right away (no new sign-in needed)', r.status === 200 && r.json.signedIn === false, r);
r = await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'search'] }, 'PUT'); r = await sam.call(q); ok('removing Calendar takes effect immediately', r.status === 403, r.status);
r = await sam.call('/api/search?q=private'); ok('and Search no longer reveals his calendar', r.status === 200 && r.json.events.length === 0 && r.json.tasks.length === 1, r.json);
await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'calendar', 'search'] }, 'PUT');
// ---- Plex sign-in is stored per person (encrypted), and the proxy uses only the caller's own
const seen = [];
const fakePlex = require('http').createServer((rq, rs) => { seen.push({ url: rq.url, token: rq.headers['x-plex-token'] }); rs.writeHead(200, { 'content-type': 'application/json' }); rs.end(JSON.stringify({ MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } })); });
await new Promise(res => fakePlex.listen(0, '127.0.0.1', res)); const plexPort = fakePlex.address().port;
const sealPlex = obj => { const key = crypto.scryptSync(SECRET, 'nehub-plex', 32), iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key, iv); const d = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'calendar', 'search', 'music'] }, 'PUT');
fs.writeFileSync(path.join(dir, 'users', me.id, 'plex.json'), JSON.stringify({ data: sealPlex({ token: 'ADMIN-PLEX-TOKEN', srv: 'http://127.0.0.1:' + plexPort, stk: 'ADMIN-SERVER-TOKEN', name: 'Home Plex' }) }));
r = await admin.call('/api/plex/status'); ok("a person's saved Plex sign-in is used: signed in, with the chosen server", r.json.signedIn === true && r.json.server === 'Home Plex', r.json);
r = await sam.call('/api/plex/status'); ok("another person is NOT signed in to the first person's Plex", r.json.signedIn === false && r.json.server === null, r.json);
r = await admin.call('/plex/library/sections'); ok("the admin's Plex requests reach the server with the admin's own server token", r.status === 200 && seen.length === 1 && seen[0].token === 'ADMIN-SERVER-TOKEN' && seen[0].url === '/library/sections', [r.status, seen]);
r = await sam.call('/plex/library/sections'); ok("another person's Plex requests are refused and never reach the admin's server", r.status === 401 && seen.length === 1, [r.status, seen.length]);
ok('the Plex tokens are not readable in the file on disk', !fs.readFileSync(path.join(dir, 'users', me.id, 'plex.json'), 'utf8').includes('ADMIN-PLEX-TOKEN'));
r = await admin.call('/api/plex/logout', {}); r = await admin.call('/api/plex/status'); ok('signing out of Plex removes it', r.json.signedIn === false && !fs.existsSync(path.join(dir, 'users', me.id, 'plex.json')), r.json);
fakePlex.close();
await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'calendar', 'search'] }, 'PUT');
// ---- another device for the same person
r = await admin.call('/api/users/' + samUser.id + '/invite', {}); const code2 = r.json.code;
r = await register(sam, dS2, { inviteCode: code2 }, 'Sam tablet', base);
const sam2 = client(base); const lr = await login(sam2, dS2, base);
r = await sam2.call('/api/me'); ok("an invite for Sam's second device signs in as Sam, not as someone new", lr.status === 200 && r.json.id === samUser.id && r.json.role === 'user', r.json);
r = await sam.call('/api/auth/passkeys'); const ar = await admin.call('/api/auth/passkeys');
ok('each person sees only their own passkeys', r.json.length === 2 && ar.json.length === 1, [r.json.length, ar.json.length]);
r = await sam.call('/api/auth/passkeys', { id: ar.json[0].id }, 'DELETE'); ok("nobody can remove another person's passkey", r.status === 404, r);
// ---- sign out everywhere, disable, enable
const oldCookie = sam.cookie;
r = await admin.call('/api/users/' + samUser.id, { signOutEverywhere: true }, 'PUT');
r = await sam.call('/api/me'); ok('"sign out everywhere" ends every existing session of that person', r.status === 401, r.status);
const sam3 = client(base); r = await login(sam3, dS, base); ok('they can sign in again with their passkey afterwards', r.status === 200 && (await sam3.call('/api/me')).json.id === samUser.id);
r = await admin.call('/api/users/' + samUser.id, { disabled: true }, 'PUT');
r = await sam3.call('/api/me'); ok('turning someone off signs them out at once', r.status === 401, r.status);
r = await login(client(base), dS, base); ok('and their passkey no longer works', r.status === 403, r.status);
r = await admin.call('/api/users/' + samUser.id + '/invite', {}); r = await register(client(base), device(), { inviteCode: r.json.code }, 'x', base); ok('an invite for a turned-off account is refused', r.status === 403, r.status);
r = await admin.call('/api/users/' + samUser.id, { disabled: false }, 'PUT'); r = await login(client(base), dS, base); ok('turning them back on restores sign-in', r.status === 200, r.status);
r = await admin.call('/api/users/' + me.id, { disabled: true }, 'PUT'); ok('the admin cannot turn off their own account', r.status === 400, r);
r = await admin.call('/api/users/' + me.id, { role: 'user' }, 'PUT'); ok('the only admin cannot be demoted', r.status === 400, r);
r = await admin.call('/api/users/' + me.id, undefined, 'DELETE'); ok('the admin cannot delete themselves', r.status === 400, r);
// ---- a second person, then delete Sam
r = await admin.call('/api/users', { name: 'Kim', tabs: ['files', 'tasks'] }); const kimUser = r.json.user; r = await register(kim, dK, { inviteCode: r.json.code }, 'Kim phone', base);
r = await kim.call('/sf/api2/ping/'); ok('Kim, who has Files, passes the access check (Seafile itself is unreachable in this test: 502, not 403)', r.status !== 403 && r.status !== 401, r.status);
r = await kim.call('/api/cal/list'); ok('Kim, without Calendar, is refused it', r.status === 403, r.status);
r = await kim.call('/api/search?q=x'); ok('Kim, without Search, is refused it (and the web-answer lookup)', r.status === 403 && (await kim.call('/api/ddg?q=einstein')).status === 403, r.status);
r = await admin.call('/api/users'); ok('the people list shows both, with passkey counts', r.json.users.length === 3 && r.json.users.find(u => u.id === samUser.id).passkeys === 2, r.json.users.map(u => [u.name, u.passkeys]));
const samDir = path.join(dir, 'users', samUser.id); ok("Sam's data folder exists", fs.existsSync(samDir) && fs.existsSync(path.join(samDir, 'tasks.json')));
r = await admin.call('/api/users/' + samUser.id, undefined, 'DELETE'); ok('admin deletes Sam', r.status === 200, r);
r = await sam3.call('/api/me'); ok('his sessions stop working', r.status === 401, r.status);
r = await login(client(base), dS, base); ok('his passkeys are gone', r.status === 400, r.status);
ok("his data folder is deleted, the admin's and Kim's are untouched", !fs.existsSync(samDir) && fs.existsSync(path.join(dir, 'users', me.id, 'tasks.json')) && fs.existsSync(path.join(dir, 'users', kimUser.id)));
r = await admin.call('/api/tasks?list=local:inbox'); ok("the admin's own data is intact", r.json.map(t => t.title).join() === 'Admin private task', r.json);
const mode = f => (fs.statSync(f).mode & 0o777).toString(8);
ok('per-person data files are private (0600)', mode(path.join(dir, 'users', me.id, 'tasks.json')) === '600' && mode(path.join(dir, 'users.json')) === '600', [mode(path.join(dir, 'users.json'))]);
ok('the server survived all of it', srv.alive);
// ---- everything is remembered after a restart
srv.child.kill(); await wait(300); const srv2 = start(dir, port); await ready(base);
r = await admin.call('/api/me'); ok('after a restart the admin is still signed in', r.status === 200 && r.json.role === 'admin', r.status);
r = await kim.call('/api/me'); ok('and Kim, with the same tabs', r.status === 200 && r.json.tabs.includes('files') && !r.json.tabs.includes('calendar'), r.json);
srv2.child.kill();
// ================= Part 2: an existing single-user install becomes the admin =================
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-migrate-')), port2 = port + 1, base2 = `http://localhost:${port2}`;
const legacyDev = device();
const legacyCred = { id: enc(legacyDev.id), publicKey: 'AAAA', counter: 0, transports: [], name: 'Old laptop', created: Date.now() };
fs.writeFileSync(path.join(dir2, 'passkeys.json'), JSON.stringify({ userId: 'bGVnYWN5LWhhbmRsZQ', creds: [legacyCred] }));
fs.writeFileSync(path.join(dir2, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'Existing task', notes: '', due: '', done: false }] }));
fs.writeFileSync(path.join(dir2, 'settings.json'), JSON.stringify({ task_list: 'local:inbox' }));
fs.writeFileSync(path.join(dir2, 'calendars.json'), JSON.stringify([]));
const srv3 = start(dir2, port2); await ready(base2);
const old = client(base2);
const exp = Date.now() + 864e5, legacySig = crypto.createHmac('sha256', SECRET).update('s' + exp).digest('base64url'); old.cookie = `hub_session=${exp}.${legacySig}`;
r = await old.call('/api/me'); ok('migration: an existing session (old cookie style) still works, as the admin', r.status === 200 && r.json.role === 'admin' && r.json.name === 'Admin', r);
ok('migration: the old cookie is upgraded to the new style', r.set.some(c => /^hub_session=u[0-9a-f]+\.1\./.test(c)), r.set);
r = await old.call('/api/tasks?list=local:inbox'); ok('migration: existing tasks appear for the admin', r.json.map(t => t.title).join() === 'Existing task', r.json);
r = await old.call('/api/settings'); ok('migration: existing settings appear for the admin', r.json.values.task_list === 'local:inbox', r.json);
const pk = JSON.parse(fs.readFileSync(path.join(dir2, 'passkeys.json'), 'utf8')); const um = JSON.parse(fs.readFileSync(path.join(dir2, 'users.json'), 'utf8'));
ok('migration: the existing passkey now belongs to the admin account', pk.creds[0].uid === um.users[0].id && um.users[0].handle === 'bGVnYWN5LWhhbmRsZQ', [pk.creds[0].uid, um.users[0].id]);
ok('migration: the original files were left in place as a backup', fs.existsSync(path.join(dir2, 'tasks.json')) && fs.existsSync(path.join(dir2, 'users', um.users[0].id, 'tasks.json')));
r = await register(client(base2), device(), { setupCode: 'setup-code' }, 'x', base2); ok('migration: the setup code cannot be used to take over a migrated install', r.status === 403, r);
r = await client(base2).call('/api/tasks/lists'); ok('signed-out requests are still refused', r.status === 401, r.status);
ok('the migrated server is still running', srv3.alive);
srv3.child.kill();
// ================= Part 3: iCloud is connected per person =================
const dir3 = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-icloud-')), port3 = port + 3, base3 = `http://localhost:${port3}`, calPort = port + 4;
const store3 = new Map(); const W = b => `<?xml version="1.0"?><multistatus xmlns="DAV:">${b}</multistatus>`; const esc = t => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
store3.set('/9/calendars/home/e1.ics', 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:e1\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:Admin iCloud event\r\nDTSTART;VALUE=DATE:20261103\r\nDTEND;VALUE=DATE:20261104\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n');
store3.set('/9/calendars/rem/t1.ics', 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:Admin iCloud reminder\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n');
const fakeApple = require('http').createServer((rq, rs) => { const c = []; rq.on('data', d => c.push(d)); rq.on('end', () => {
const body = Buffer.concat(c).toString(), p = new URL(rq.url, 'http://x').pathname, x = b => { rs.writeHead(207, { 'content-type': 'text/xml' }); rs.end(W(b)); };
if (Buffer.from((rq.headers.authorization || '').replace('Basic ', ''), 'base64').toString() !== 'a@icloud.com:abcd-efgh-ijkl-mnop') { rs.writeHead(401); return rs.end(); }
if (rq.method === 'PROPFIND' && p === '/') return x('<response><href>/</href><propstat><prop><current-user-principal><href>/9/principal/</href></current-user-principal></prop><status>HTTP/1.1 200 OK</status></propstat></response>');
if (rq.method === 'PROPFIND' && p === '/9/principal/') return x(`<response><href>/9/principal/</href><propstat><prop><calendar-home-set xmlns="urn:ietf:params:xml:ns:caldav"><href xmlns="DAV:">http://localhost:${calPort}/9/calendars/</href></calendar-home-set></prop><status>HTTP/1.1 200 OK</status></propstat></response>`);
if (rq.method === 'PROPFIND' && p === '/9/calendars/') { const cal = (h, n, comp) => `<response><href>${h}</href><propstat><prop><displayname>${n}</displayname><resourcetype><collection/><calendar xmlns="urn:ietf:params:xml:ns:caldav"/></resourcetype><supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav"><comp name="${comp}"/></supported-calendar-component-set></prop><status>HTTP/1.1 200 OK</status></propstat></response>`; return x(cal('/9/calendars/home/', 'Home', 'VEVENT') + cal('/9/calendars/rem/', 'Reminders', 'VTODO')); }
if (rq.method === 'REPORT') { const dir = p.replace(/\/?$/, '/'), wantTodo = /comp-filter name="VTODO"/.test(body); let o = ''; for (const [k, v] of store3) if (k.startsWith(dir) && wantTodo === /BEGIN:VTODO/.test(v)) o += `<response><href>${k}</href><propstat><prop><getetag>"1"</getetag><calendar-data>${esc(v)}</calendar-data></prop><status>HTTP/1.1 200 OK</status></propstat></response>`; return x(o); }
rs.writeHead(404); rs.end(); }); });
await new Promise(res => fakeApple.listen(calPort, '127.0.0.1', res));
const srv4 = start(dir3, port3, { CALDAV_TEST: '1', CALDAV_BASE: `http://localhost:${calPort}` }); await ready(base3);
const a3 = client(base3), s3 = client(base3), dA3 = device(), dS3 = device();
await register(a3, dA3, { setupCode: 'setup-code' }, 'Admin', base3);
r = await a3.call('/api/users', { name: 'Sam', tabs: ['calendar', 'tasks'] }); await register(s3, dS3, { inviteCode: r.json.code }, 'Sam', base3);
r = await a3.call('/api/cal/icloud/connect', { appleId: 'a@icloud.com', password: 'abcd-efgh-ijkl-mnop' }); ok("the admin connects iCloud (one event calendar, one reminder list)", r.status === 200 && r.json.connected && r.json.calendars.length === 1 && r.json.reminderLists === 1, r.json);
const q3 = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
r = await a3.call(q3); ok('the admin sees their iCloud event', r.json.events.some(e => e.title === 'Admin iCloud event'), r.json.events);
r = await s3.call(q3); ok("another person does NOT see the admin's iCloud event", !r.json.events.some(e => e.title === 'Admin iCloud event') && !r.json.errors.length, r.json);
r = await s3.call('/api/cal/icloud'); ok('another person is not connected to iCloud', r.json.connected === false, r.json);
r = await a3.call('/api/tasks/lists'); const remList = r.json.lists.find(l => l.icloud); ok("the admin has the iCloud reminder list", !!remList, r.json);
r = await s3.call('/api/tasks/lists'); ok("another person's task lists do not include the admin's iCloud reminders", !r.json.lists.some(l => l.icloud), r.json);
r = await s3.call('/api/tasks?list=' + encodeURIComponent(remList.id)); ok("and cannot read the admin's iCloud reminders by guessing the list id", r.status === 404, r);
r = await a3.call('/api/tasks?list=' + encodeURIComponent(remList.id)); ok("the admin can read their reminders", r.json.some(t => t.title === 'Admin iCloud reminder'), r.json);
r = await s3.call('/api/cal/event', { cal: remList.id.replace('icloud:', 'icloud:'), fields: { title: 'x', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } }); ok("another person cannot write into the admin's iCloud calendars", r.status === 400, r);
ok("the iCloud login is stored encrypted inside the admin's own folder only", !fs.readFileSync(path.join(dir3, 'users', JSON.parse(fs.readFileSync(path.join(dir3, 'users.json'))).users[0].id, 'icloud.json'), 'utf8').includes('abcd-efgh') && !fs.existsSync(path.join(dir3, 'icloud.json')));
srv4.child.kill(); fakeApple.close();
ok('the iCloud-enabled server ran without errors', !/\[error\]|Error:/.test(srv4.log.replace(/Error: unsupported/g, '')), srv4.log.split('\n').filter(l => /error/i.test(l)).slice(0, 3));
fs.rmSync(dir3, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir2, { recursive: true, force: true });
if (failed) console.log('\n--- server log (errors) ---\n' + srv.log.split('\n').filter(l => /error|Error/.test(l)).slice(0, 10).join('\n'));
console.log(failed ? `\n${failed} check(s) failed` : '\nAll account checks passed');
process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });