Files
dashcaddy/dashcaddy-api/__tests__/invite-store.test.js
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

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);
});
});