DC-048: multi-user bootstrap + admin invites (opt-in)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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.
This commit is contained in:
hermes
2026-07-20 17:44:11 -07:00
parent bd480a69a7
commit 321334cd33
23 changed files with 2964 additions and 325 deletions
@@ -0,0 +1,374 @@
/**
* Tests for DC-048 auth flow integration:
* - email login: first user = bootstrap admin (no allowlist needed)
* - email login: subsequent user without allowlist = rejected
* - email login: subsequent user with allowlist = operator role
* - email login: token consumption is atomic (replay = already_used)
* - TOTP login: tags req.user with system-admin record (audit attribution)
* - admin routes: /me returns the right shape
* - admin routes: 403 for non-admin on /admin/*
* - invite flow: issue → email → accept → user created with role
*
* Strategy: build the EmailMagicLinkProvider + a TOTP stub + the admin router
* with an in-process user store. No HTTP server; we call the handlers
* directly with mock req/res.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
function _tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-integration-'));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('DC-048: opt-in user store', () => {
let dir;
beforeEach(() => { dir = _tmpDir(); });
afterEach(() => _cleanup(dir));
test('userStore is null until email auth is explicitly enabled', () => {
// The wiring code in routes/auth/index.js checks:
// siteConfig.authProviders.email.enabled === true
// If false, userStore stays null and providers fall back to legacy
// "allow everyone" semantics. This test simulates that branch by
// checking the flag path directly.
const siteConfig = { authProviders: { email: { enabled: false } } };
const emailEnabled =
siteConfig.authProviders && siteConfig.authProviders.email && siteConfig.authProviders.email.enabled === true;
expect(emailEnabled).toBe(false);
});
test('userStore activates when email auth is explicitly enabled', () => {
const siteConfig = { authProviders: { email: { enabled: true } } };
const emailEnabled =
siteConfig.authProviders && siteConfig.authProviders.email && siteConfig.authProviders.email.enabled === true;
expect(emailEnabled).toBe(true);
});
});
describe('DC-048: email magic-link auth attribution', () => {
let dir, userStore;
beforeEach(() => {
dir = _tmpDir();
userStore = require('../src/security/user-store').createUserStore({ dataDir: dir });
});
afterEach(() => _cleanup(dir));
test('first email = bootstrap admin', async () => {
const r = await userStore.login({ email: 'admin@example.com', ip: '127.0.0.1' });
expect(r.ok).toBe(true);
expect(r.isBootstrap).toBe(true);
expect(r.role).toBe('admin');
});
test('second email without allowlist rejected', async () => {
await userStore.login({ email: 'admin@example.com' });
const r = await userStore.login({ email: 'stranger@example.com' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('not_authorized');
});
test('second email WITH allowlist = operator role', async () => {
await userStore.login({ email: 'admin@example.com' });
await userStore.addToAllowlist('friend@example.com');
const r = await userStore.login({ email: 'friend@example.com' });
expect(r.ok).toBe(true);
expect(r.role).toBe('operator');
expect(r.isBootstrap).toBe(false);
});
test('isEmailAuthorized returns false after bootstrap for non-allowlisted', async () => {
await userStore.login({ email: 'admin@example.com' });
expect(await userStore.isEmailAuthorized('random@example.com')).toBe(false);
await userStore.addToAllowlist('random@example.com');
expect(await userStore.isEmailAuthorized('random@example.com')).toBe(true);
});
});
describe('DC-048: email provider auth flow with userStore', () => {
let dir, userStore, EmailProvider;
beforeEach(() => {
dir = _tmpDir();
userStore = require('../src/security/user-store').createUserStore({ dataDir: dir });
EmailProvider = require('../src/auth/providers/email');
});
afterEach(() => _cleanup(dir));
function _makeProvider() {
// Real session stub — record create/setCookie calls without cookie IO.
const session = {
create: jest.fn(),
setCookie: jest.fn(),
isSessionValid: () => true,
getClientIP: (req) => req.ip || '127.0.0.1',
};
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
const provider = new EmailProvider({
config: { enabled: true, sessionDuration: '24h' },
log,
session,
renewCSRFToken: () => 'csrf-token-stub',
siteConfig: {},
userStore,
platformPaths: { dataDir: dir },
});
return { provider, session, log };
}
function _fakeReqRes({ body, query, ip, headers } = {}) {
const req = {
body: body || {},
query: query || {},
ip: ip || '127.0.0.1',
socket: { remoteAddress: ip || '127.0.0.1' },
headers: headers || {},
protocol: 'https',
secure: true,
};
const res = {
_status: 200,
_body: null,
status(c) { this._status = c; return this; },
json(b) { this._body = b; return this; },
cookie: jest.fn(),
setHeader: jest.fn(),
getHeader: () => undefined,
};
return { req, res };
}
test('initiate returns sent:true even for unauthorized email (enumeration prevention)', async () => {
const { provider } = _makeProvider();
// Bootstrap first.
await userStore.login({ email: 'admin@x.com' });
// Now an unauthorized user tries.
const { req, res } = _fakeReqRes({ body: { email: 'stranger@x.com' } });
await provider.initiate('magic-link', req, res);
expect(res._body.sent).toBe(true);
});
test('verify rejects unauthorized email after bootstrap', async () => {
const { provider } = _makeProvider();
await userStore.login({ email: 'admin@x.com' });
// Issue token for an unauthorized user (provider's initiate still creates
// a token — the verify step is where authorization is enforced).
const initReq = _fakeReqRes({ body: { email: 'stranger@x.com' } });
await provider.initiate('magic-link', initReq.req, initReq.res);
// The token was returned to the user as part of dev-console log.
// Grab the dev marker from the log mock to extract the URL → token.
const warnCalls = provider.deps.log.warn.mock.calls;
const marker = warnCalls.find(c => c[1] && c[1].includes('stranger@x.com'));
expect(marker).toBeTruthy();
const urlMatch = marker[1].match(/url=(\S+)/);
expect(urlMatch).toBeTruthy();
const url = new URL(urlMatch[1]);
const token = url.searchParams.get('token');
// Now verify — should reject.
const { req, res } = _fakeReqRes({ body: { token }, ip: '127.0.0.1' });
await expect(provider.verify('verify-token', req, res)).rejects.toThrow();
});
test('verify accepts authorized email + creates user record', async () => {
const { provider, session } = _makeProvider();
await userStore.login({ email: 'admin@x.com' });
await userStore.addToAllowlist('friend@x.com');
const initReq = _fakeReqRes({ body: { email: 'friend@x.com' } });
await provider.initiate('magic-link', initReq.req, initReq.res);
const marker = provider.deps.log.warn.mock.calls
.find(c => c[1] && c[1].includes('friend@x.com'));
const url = new URL(marker[1].match(/url=(\S+)/)[1]);
const token = url.searchParams.get('token');
const { req, res } = _fakeReqRes({ body: { token } });
await provider.verify('verify-token', req, res);
// Session was created.
expect(session.create).toHaveBeenCalledTimes(1);
expect(session.setCookie).toHaveBeenCalledTimes(1);
// User record exists.
const u = await userStore.getUserByEmail('friend@x.com');
expect(u).toBeTruthy();
expect(u.role).toBe('operator');
// req.user was tagged for audit attribution.
expect(req.user.id).toBe(u.id);
expect(req.user.role).toBe('operator');
expect(req.user.isBootstrap).toBe(false);
// Response includes user info.
expect(res._body.user.email).toBe('friend@x.com');
expect(res._body.user.role).toBe('operator');
});
test('verify rejects second use of same token (replay protection)', async () => {
const { provider } = _makeProvider();
// Bootstrap.
const { req: bReq, res: bRes } = _fakeReqRes({ body: { email: 'admin@x.com' } });
await provider.initiate('magic-link', bReq, bRes);
const marker = provider.deps.log.warn.mock.calls
.find(c => c[1] && c[1].includes('admin@x.com'));
const url = new URL(marker[1].match(/url=(\S+)/)[1]);
const token = url.searchParams.get('token');
// First verify succeeds.
const { req: v1Req, res: v1Res } = _fakeReqRes({ body: { token } });
await provider.verify('verify-token', v1Req, v1Res);
expect(v1Res._body.message).toBe('Authenticated successfully');
// Second verify fails with generic message.
const { req: v2Req, res: v2Res } = _fakeReqRes({ body: { token } });
await expect(provider.verify('verify-token', v2Req, v2Res)).rejects.toThrow(/invalid/);
});
});
describe('DC-048: admin routes /me + /admin/users', () => {
let dir, userStore, adminRouter;
beforeEach(() => {
dir = _tmpDir();
userStore = require('../src/security/user-store').createUserStore({ dataDir: dir });
// Seed: bootstrap admin
userStore.login({ email: 'admin@x.com' });
const initAdmin = require('../routes/auth/admin');
adminRouter = initAdmin({
asyncHandler: (fn) => fn,
errorResponse: (_res, code, msg) => {
const err = new Error(msg); err.statusCode = code; throw err;
},
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
session: null,
dataDir: dir,
});
});
afterEach(() => _cleanup(dir));
function _invoke(method, urlPath, { user } = {}) {
const req = {
method,
url: urlPath,
path: urlPath.split('?')[0],
query: {},
body: {},
headers: {},
ip: '127.0.0.1',
params: {},
user,
app: { locals: {} },
};
// Parse path into Express-style params
for (const layer of adminRouter.stack) {
if (layer.route && layer.route.methods[method.toLowerCase()]) {
const routePath = layer.route.path;
// Simple :param parsing for tests
const expectedParts = routePath.split('/').filter(Boolean);
const actualParts = req.path.split('/').filter(Boolean);
if (expectedParts.length !== actualParts.length) continue;
let match = true;
for (let i = 0; i < expectedParts.length; i++) {
if (expectedParts[i].startsWith(':')) {
req.params[expectedParts[i].slice(1)] = actualParts[i];
} else if (expectedParts[i] !== actualParts[i]) {
match = false; break;
}
}
if (match) {
const res = {
_status: 200,
_body: null,
status(c) { this._status = c; return this; },
json(b) { this._body = b; return this; },
};
// The router layer's .route.stack contains the middleware chain
// (e.g. _requireAdmin) + the actual handler. We walk the chain
// manually since we're bypassing Express.
const handlers = layer.route.stack.map(s => s.handle);
return {
layer, req, res,
run: async () => {
for (let i = 0; i < handlers.length; i++) {
const h = handlers[i];
const isLast = i === handlers.length - 1;
const stepResult = await new Promise((resolveStep, rejectStep) => {
let nextCalled = false;
let nextErr = null;
const next = (err) => {
nextCalled = true;
nextErr = err || null;
resolveStep({ nextCalled, nextErr });
};
try {
const ret = h(req, res, next);
if (ret && typeof ret.then === 'function') {
ret.then(() => {
if (!nextCalled) resolveStep({ nextCalled, nextErr });
}).catch(rejectStep);
} else if (!nextCalled) {
// Synchronous handler that didn't call next — assume it's the
// final handler that wrote to res. Resolve.
resolveStep({ nextCalled, nextErr });
}
} catch (e) { rejectStep(e); }
});
if (stepResult.nextErr) throw stepResult.nextErr;
if (!stepResult.nextCalled && !isLast) {
throw new Error('middleware chain did not call next');
}
}
},
};
}
}
}
return null;
}
test('/me returns admin user info when authenticated', async () => {
const admin = (await userStore.listUsers())[0];
const r = _invoke('GET', '/me', { user: { id: admin.id, email: admin.email, role: 'admin' } });
await r.run();
expect(r.res._body.authenticated).toBe(true);
expect(r.res._body.role).toBe('admin');
expect(r.res._body.user.email).toBe('admin@x.com');
});
test('/me returns legacy:true when no user attributed', async () => {
const r = _invoke('GET', '/me', { user: null });
await r.run();
expect(r.res._body.legacy).toBe(true);
expect(r.res._body.role).toBe('admin'); // legacy compat
});
test('/admin/users requires admin role (403 for non-admin)', async () => {
const r = _invoke('GET', '/admin/users', { user: { id: 'fake', email: 'x@x.com', role: 'viewer' } });
let caught = null;
try { await r.run(); } catch (e) { caught = e; }
expect(caught).toBeTruthy();
expect(caught.statusCode).toBe(403);
});
test('/admin/users returns user list for admin', async () => {
const r = _invoke('GET', '/admin/users', { user: { id: 'admin-id', email: 'admin@x.com', role: 'admin' } });
await r.run();
expect(Array.isArray(r.res._body.users)).toBe(true);
expect(r.res._body.users).toHaveLength(1);
expect(r.res._body.users[0].email).toBe('admin@x.com');
});
test('/admin/users POST adds to allowlist', async () => {
const r = _invoke('POST', '/admin/users', {
user: { id: 'admin-id', email: 'admin@x.com', role: 'admin' },
});
r.req.body = { email: 'newfriend@x.com' };
await r.run();
const allowlist = await userStore.listAllowlist();
expect(allowlist).toContain('newfriend@x.com');
});
});