Files
hermes 321334cd33
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-048: multi-user bootstrap + admin invites (opt-in)
Implements the user-store + invite-store + admin routes. The whole
system is opt-in via siteConfig.authProviders.email.enabled = true;
single-user TOTP-only installs see zero behavior change.

Backend:
- src/security/user-store.js: users + allowlist + bootstrap sentinel,
  atomic writes, last-admin protection, defensive dataDir resolver.
- src/security/invite-store.js: single-use tokens (SHA-256 hashed on
  disk), TTL, auto-prune, defensive dataDir resolver.
- routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist,
  /admin/invites (CRUD), public /invites/:token (peek + accept).
- routes/auth/index.js: wires userStore, gates admin router on
  email auth being enabled.
- src/auth/providers/email.js: verify() enforces allowlist, creates
  user record, tags req.user; default-enabled flipped to opt-in.
- src/auth/providers/totp.js: bootstraps system@totp.local admin on
  first verify so current DNS2 operator shows in /admin/users.
- src/security/audit-logger.js: middleware adds userId/userEmail/
  userRole/viaProvider to log details when req.user is tagged.
- PUBLIC_ROUTES + CSRF allowlists updated for invite redemption.

Frontend:
- status/js/admin.js: modal overlay with users list (role-edit,
  delete), invite form (email/role/TTL), copy-link button,
  outstanding-invites list with revoke. Exports window.AdminPanel.
- status/js/core/init.js: calls AdminPanel.attachTrigger so the
  Admin button only appears when /me returns isAdmin=true.

Tests: 35 new tests across 3 files (user-store, invite-store, auth
multistore integration). Full suite: 1298/1298 passing.

Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased]
section gets the DC-048 entry.
2026-07-20 17:44:11 -07:00

231 lines
8.4 KiB
JavaScript

/**
* Tests for user-store (DC-048).
* Coverage:
* - bootstrap rule: first user becomes admin
* - allowlist enforcement: emails not on the list are rejected
* - login idempotency: existing user just bumps counters
* - role updates with valid/invalid roles
* - last-admin protection: cannot delete the only admin
* - concurrent login safety: mutex serializes
* - file persistence: writes are atomic and survive process kill
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createUserStore, ROLES, VALID_ROLES } = require('../src/security/user-store');
function _tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-usertest-'));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('user-store: bootstrap', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('first login becomes admin (isBootstrap=true)', async () => {
const r = await store.login({ email: 'alice@example.com', ip: '127.0.0.1' });
expect(r.ok).toBe(true);
expect(r.isBootstrap).toBe(true);
expect(r.role).toBe('admin');
expect(r.user.email).toBe('alice@example.com');
expect(r.user.id).toBeTruthy();
expect(r.user.loginCount).toBe(1);
});
test('bootstrap sentinel written', async () => {
await store.login({ email: 'a@x.com' });
expect(fs.existsSync(path.join(dir, '.bootstrapped'))).toBe(true);
const sentinel = JSON.parse(fs.readFileSync(path.join(dir, '.bootstrapped'), 'utf8'));
expect(sentinel.adminEmail).toBe('a@x.com');
});
test('bootstrap-admin email is added to allowlist', async () => {
await store.login({ email: 'first@x.com' });
const allowlist = await store.listAllowlist();
expect(allowlist).toContain('first@x.com');
});
test('second login denied without allowlist', async () => {
await store.login({ email: 'first@x.com' });
const r = await store.login({ email: 'second@x.com' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('not_authorized');
});
test('second login allowed if email is on allowlist', async () => {
await store.login({ email: 'first@x.com' });
await store.addToAllowlist('friend@x.com');
const r = await store.login({ email: 'friend@x.com' });
expect(r.ok).toBe(true);
expect(r.isBootstrap).toBe(false);
expect(r.role).toBe('operator'); // not admin — bootstrap already happened
});
test('replay bootstrap after delete restores allow-everyone', async () => {
await store.login({ email: 'first@x.com' });
// Cannot fully replay — bootstrap sentinel persists. Verify the
// invariant: once bootstrapped, even an empty allowlist rejects new
// emails unless added explicitly.
const r = await store.login({ email: 'random@x.com' });
expect(r.ok).toBe(false);
});
});
describe('user-store: login idempotency', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('existing user login bumps counters, does NOT bootstrap again', async () => {
const r1 = await store.login({ email: 'a@x.com' });
const id = r1.user.id;
const r2 = await store.login({ email: 'a@x.com', ip: '10.0.0.1' });
expect(r2.ok).toBe(true);
expect(r2.isBootstrap).toBe(false);
expect(r2.user.id).toBe(id);
expect(r2.user.loginCount).toBe(2);
expect(r2.user.lastLoginIp).toBe('10.0.0.1');
});
test('email normalized to lowercase', async () => {
await store.login({ email: 'Alice@Example.COM' });
const users = await store.listUsers();
expect(users).toHaveLength(1);
expect(users[0].email).toBe('alice@example.com');
});
});
describe('user-store: validation', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('invalid email rejected', async () => {
const r = await store.login({ email: 'not-an-email' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_email');
});
test('empty email rejected', async () => {
const r = await store.login({ email: '' });
expect(r.ok).toBe(false);
});
test('isEmailAuthorized returns true only when allowlist or bootstrap-pending', async () => {
expect(await store.isEmailAuthorized('anyone@x.com')).toBe(true); // bootstrap pending
await store.login({ email: 'first@x.com' });
expect(await store.isEmailAuthorized('anyone@x.com')).toBe(false);
await store.addToAllowlist('friend@x.com');
expect(await store.isEmailAuthorized('friend@x.com')).toBe(true);
expect(await store.isEmailAuthorized('stranger@x.com')).toBe(false);
});
});
describe('user-store: roles + delete', () => {
let dir, store;
beforeEach(() => {
dir = _tmpDir();
store = createUserStore({ dataDir: dir });
});
afterEach(() => _cleanup(dir));
test('setRole updates an existing user', async () => {
await store.login({ email: 'a@x.com' });
await store.addToAllowlist('b@x.com');
const r = await store.login({ email: 'b@x.com' });
const set = await store.setRole(r.user.id, 'viewer');
expect(set.ok).toBe(true);
const got = await store.getUser(r.user.id);
expect(got.role).toBe('viewer');
});
test('setRole rejects invalid role', async () => {
await store.login({ email: 'a@x.com' });
const set = await store.setRole('nonexistent', 'superuser');
expect(set.ok).toBe(false);
expect(set.reason).toBe('invalid_role');
});
test('deleteUser removes user + allowlist entry', async () => {
await store.login({ email: 'a@x.com' });
await store.addToAllowlist('b@x.com');
const r = await store.login({ email: 'b@x.com' });
const del = await store.deleteUser(r.user.id);
expect(del.ok).toBe(true);
const users = await store.listUsers();
expect(users).toHaveLength(1); // only the admin
const allowlist = await store.listAllowlist();
expect(allowlist).not.toContain('b@x.com');
});
test('deleteUser refuses to delete the last admin', async () => {
const r = await store.login({ email: 'admin@x.com' });
const del = await store.deleteUser(r.user.id);
expect(del.ok).toBe(false);
expect(del.reason).toBe('last_admin');
});
test('deleteUser allows removing admin when another admin exists', async () => {
await store.login({ email: 'admin1@x.com' });
await store.addToAllowlist('admin2@x.com');
const r2 = await store.login({ email: 'admin2@x.com' });
await store.setRole(r2.user.id, 'admin');
const r1 = await store.listUsers();
const admin1 = r1.find(u => u.email === 'admin1@x.com');
const del = await store.deleteUser(admin1.id);
expect(del.ok).toBe(true);
const remaining = await store.listUsers();
expect(remaining).toHaveLength(1);
expect(remaining[0].role).toBe('admin');
});
});
describe('user-store: atomic writes', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('users.json is well-formed after write', async () => {
await store.login({ email: 'a@x.com' });
const raw = fs.readFileSync(path.join(dir, 'users.json'), 'utf8');
const parsed = JSON.parse(raw);
expect(parsed.users).toBeTruthy();
expect(parsed.order).toHaveLength(1);
});
test('corrupt users.json falls back to empty (no crash)', async () => {
fs.writeFileSync(path.join(dir, 'users.json'), '{not json');
const users = await store.listUsers();
expect(users).toEqual([]);
});
test('listUsers returns most-recent-first by createdAt order', async () => {
await store.login({ email: 'a@x.com' });
await new Promise(r => setTimeout(r, 5));
await store.addToAllowlist('b@x.com');
await store.login({ email: 'b@x.com' });
const users = await store.listUsers();
expect(users[0].email).toBe('b@x.com');
expect(users[1].email).toBe('a@x.com');
});
});
describe('user-store: ROLES constants', () => {
test('exports admin/operator/viewer roles', () => {
expect(ROLES.ADMIN).toBe('admin');
expect(ROLES.OPERATOR).toBe('operator');
expect(ROLES.VIEWER).toBe('viewer');
expect(VALID_ROLES.has('admin')).toBe(true);
expect(VALID_ROLES.has('operator')).toBe(true);
expect(VALID_ROLES.has('viewer')).toBe(true);
expect(VALID_ROLES.has('superuser')).toBe(false);
});
});