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