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.
191 lines
6.7 KiB
JavaScript
191 lines
6.7 KiB
JavaScript
/**
|
|
* Tests for invite-store (DC-048).
|
|
* Coverage:
|
|
* - issue returns raw token + id; token is 256-bit entropy
|
|
* - peek returns public-safe info without consuming
|
|
* - accept consumes + marks used, second accept returns already_used
|
|
* - expired token returns expired on accept
|
|
* - revoke removes by id
|
|
* - listOutstanding hides used/expired
|
|
* - peek returns null for unknown/used/expired (no enumeration)
|
|
* - token hash never leaves the store (only SHA-256 on disk)
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { createInviteStore, DEFAULT_TTL_MS } = require('../src/security/invite-store');
|
|
|
|
function _tmpDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-invitetest-'));
|
|
}
|
|
|
|
function _cleanup(dir) {
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
}
|
|
|
|
describe('invite-store: issue', () => {
|
|
let dir, store;
|
|
beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('issue returns raw token + id + email + role + expiresAt', async () => {
|
|
const r = await store.issue({ email: 'a@x.com', role: 'operator', ttlMs: 60_000 });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.id).toBeTruthy();
|
|
expect(typeof r.token).toBe('string');
|
|
expect(r.token.length).toBeGreaterThanOrEqual(40);
|
|
expect(r.email).toBe('a@x.com');
|
|
expect(r.role).toBe('operator');
|
|
expect(new Date(r.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
|
});
|
|
|
|
test('token is base64url and has 256 bits of entropy', async () => {
|
|
const r = await store.issue({ email: 'a@x.com' });
|
|
expect(r.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
|
|
// 32 bytes encoded → 43 chars (no padding)
|
|
expect(r.token.length).toBeGreaterThanOrEqual(42);
|
|
expect(r.token.length).toBeLessThanOrEqual(44);
|
|
});
|
|
|
|
test('on-disk JSON contains hash, not raw token', async () => {
|
|
const r = await store.issue({ email: 'a@x.com' });
|
|
const raw = fs.readFileSync(path.join(dir, 'invites.json'), 'utf8');
|
|
expect(raw).not.toContain(r.token); // raw token never touches disk
|
|
// hash is 64 hex chars
|
|
expect(raw).toMatch(/[a-f0-9]{64}/);
|
|
});
|
|
|
|
test('two issues produce different tokens', async () => {
|
|
const r1 = await store.issue({ email: 'a@x.com' });
|
|
const r2 = await store.issue({ email: 'b@x.com' });
|
|
expect(r1.token).not.toEqual(r2.token);
|
|
});
|
|
|
|
test('invalid email rejected', async () => {
|
|
const r = await store.issue({ email: 'not-an-email' });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toBe('invalid_email');
|
|
});
|
|
});
|
|
|
|
describe('invite-store: peek + accept', () => {
|
|
let dir, store;
|
|
beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('peek returns public-safe info', async () => {
|
|
const r = await store.issue({ email: 'a@x.com', role: 'operator' });
|
|
const p = await store.peek(r.token);
|
|
expect(p).toBeTruthy();
|
|
expect(p.email).toBe('a@x.com');
|
|
expect(p.role).toBe('operator');
|
|
expect(p.expiresAt).toBe(r.expiresAt);
|
|
});
|
|
|
|
test('peek does NOT consume the token', async () => {
|
|
const r = await store.issue({ email: 'a@x.com' });
|
|
await store.peek(r.token);
|
|
await store.peek(r.token);
|
|
const accept = await store.accept(r.token);
|
|
expect(accept.ok).toBe(true);
|
|
});
|
|
|
|
test('peek returns null for unknown token', async () => {
|
|
const p = await store.peek('not-a-real-token');
|
|
expect(p).toBe(null);
|
|
});
|
|
|
|
test('peek returns null for used token (no enumeration)', async () => {
|
|
const r = await store.issue({ email: 'a@x.com' });
|
|
await store.accept(r.token);
|
|
const p = await store.peek(r.token);
|
|
expect(p).toBe(null);
|
|
});
|
|
|
|
test('peek returns null for expired token (no enumeration)', async () => {
|
|
const r = await store.issue({ email: 'a@x.com', ttlMs: 1 });
|
|
await new Promise(res => setTimeout(res, 10));
|
|
const p = await store.peek(r.token);
|
|
expect(p).toBe(null);
|
|
});
|
|
|
|
test('accept marks used + records accept time', async () => {
|
|
const r = await store.issue({ email: 'a@x.com' });
|
|
const a = await store.accept(r.token, { acceptedBy: 'first@x.com' });
|
|
expect(a.ok).toBe(true);
|
|
expect(a.invite.usedAt).toBeTruthy();
|
|
expect(a.invite.email).toBe('a@x.com');
|
|
});
|
|
|
|
test('accept returns already_used on second call', async () => {
|
|
const r = await store.issue({ email: 'a@x.com' });
|
|
await store.accept(r.token);
|
|
const second = await store.accept(r.token);
|
|
expect(second.ok).toBe(false);
|
|
expect(second.reason).toBe('already_used');
|
|
});
|
|
|
|
test('accept returns expired for TTL-passed token', async () => {
|
|
const r = await store.issue({ email: 'a@x.com', ttlMs: 1 });
|
|
await new Promise(res => setTimeout(res, 10));
|
|
const a = await store.accept(r.token);
|
|
expect(a.ok).toBe(false);
|
|
expect(a.reason).toBe('expired');
|
|
});
|
|
|
|
test('accept returns not_found for unknown token', async () => {
|
|
const a = await store.accept('not-real');
|
|
expect(a.ok).toBe(false);
|
|
expect(a.reason).toBe('not_found');
|
|
});
|
|
});
|
|
|
|
describe('invite-store: revoke + listOutstanding', () => {
|
|
let dir, store;
|
|
beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
|
|
afterEach(() => _cleanup(dir));
|
|
|
|
test('revoke removes an invite', async () => {
|
|
const r = await store.issue({ email: 'a@x.com' });
|
|
const rev = await store.revoke(r.id);
|
|
expect(rev.ok).toBe(true);
|
|
const peek = await store.peek(r.token);
|
|
expect(peek).toBe(null);
|
|
});
|
|
|
|
test('revoke returns not_found for unknown id', async () => {
|
|
const r = await store.revoke('not-an-id');
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toBe('not_found');
|
|
});
|
|
|
|
test('listOutstanding excludes used + expired', async () => {
|
|
const r1 = await store.issue({ email: 'a@x.com', ttlMs: 60_000 });
|
|
const r2 = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
|
|
const r3 = await store.issue({ email: 'c@x.com', ttlMs: 1 });
|
|
await store.accept(r1.token); // used
|
|
await new Promise(res => setTimeout(res, 10)); // expire r3
|
|
|
|
const list = await store.listOutstanding();
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0].id).toBe(r2.id);
|
|
expect(list[0].email).toBe('b@x.com');
|
|
});
|
|
|
|
test('listOutstanding sorted by expiresAt', async () => {
|
|
const early = await store.issue({ email: 'a@x.com', ttlMs: 1000 });
|
|
const late = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
|
|
const list = await store.listOutstanding();
|
|
expect(list[0].id).toBe(early.id);
|
|
expect(list[1].id).toBe(late.id);
|
|
});
|
|
});
|
|
|
|
describe('invite-store: DEFAULT_TTL_MS', () => {
|
|
test('default is 24 hours', () => {
|
|
expect(DEFAULT_TTL_MS).toBe(24 * 60 * 60 * 1000);
|
|
});
|
|
}); |