/** * Tests for DC-085: link-first invite (Discord-style "share it however you want"). * * - default sendEmail omission = no email sent, link returned, no token in logs * - sendEmail:true triggers SMTP send when configured * - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked * - shareText field present and well-formed in every response * - acceptUrl always present (regardless of sendEmail) * - role + ttl validation unchanged from DC-048 * * Strategy: drive the route handler directly with mock req/res, mount the admin * router against an isolated userStore + inviteStore + email-sender stub. */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const express = require('express'); function _tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-')); } function _cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } // Stub email-sender so we can assert "was it called?" without an SMTP server. // NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()` // call is allowed to reference it (Babel guard against out-of-scope access). const mockEmailSender = { isConfigured: jest.fn(() => false), sendEmail: jest.fn(async () => undefined), }; jest.mock('../src/auth/providers/email-sender', () => mockEmailSender); describe('DC-085: link-first admin invites', () => { let dir, app, request; let logCalls; // captured { level, msg, meta } from our fake log beforeEach(async () => { jest.clearAllMocks(); dir = _tmpDir(); logCalls = []; // Set up email auth enable flag so userStore mounts. process.env.NODE_ENV = 'test'; const { createUserStore } = require('../src/security/user-store'); const userStore = createUserStore({ dataDir: dir }); // Bootstrap the admin so we have a session-attributable user. await userStore.login({ email: 'admin@sami-host.me' }); // Build a tiny Express app with the admin router mounted, but skip the // global auth gate (we inject req.user directly). const adminRouter = require('../routes/auth/admin')({ asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), errorResponse: (_res, code, msg) => ({ status: code, msg }), log: { info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }), warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }), error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }), }, session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} }, dataDir: dir, }); app = express(); app.use(express.json()); // Inject req.user = admin so /admin/* passes the role gate. app.use((req, _res, next) => { req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' }; req.app.locals = req.app.locals || {}; req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers req.app.locals.emailConfig = null; // SMTP not configured by default next(); }); app.use('/api/v1/auth', adminRouter); // Error handler — last in chain. app.use((err, _req, res, _next) => { const code = (err && err.statusCode) || 500; res.status(code).json({ success: false, error: err && err.message, code: err && err.code, }); }); request = require('supertest'); }); afterEach(() => _cleanup(dir)); test('default sendEmail (omitted) returns link and does NOT send email', async () => { const res = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'friend@example.com', role: 'operator' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(mockEmailSender.sendEmail).not.toHaveBeenCalled(); expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/); expect(res.body.deliveredVia).toBe('manual'); }); test('default sendEmail does NOT log raw token to server log', async () => { const res = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'friend@example.com', role: 'operator' }); const acceptUrl = res.body.acceptUrl; // Extract the token from the URL and verify it does NOT appear in any log call. const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1]; const tokenLeaked = logCalls.some(c => typeof c.msg === 'string' && c.msg.includes(token) ); expect(tokenLeaked).toBe(false); // Also assert no log entry mentions the URL verbatim (the old // `[DC-048-DEV-INVITE-LINK] url=...` spam). const oldSpam = logCalls.find(c => typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]') ); expect(oldSpam).toBeUndefined(); }); test('shareText is present and well-formed in every response', async () => { const res = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 }); expect(res.body.shareText).toBeDefined(); expect(res.body.shareText).toContain('Join my DashCaddy'); expect(res.body.shareText).toContain('operator'); expect(res.body.shareText).toContain(res.body.acceptUrl); expect(res.body.shareText).toContain('expires in 24h'); }); test('acceptUrl is always returned regardless of sendEmail', async () => { const r1 = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'a@x.com', sendEmail: false }); const r2 = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'b@x.com' }); expect(r1.body.acceptUrl).toBeTruthy(); expect(r2.body.acceptUrl).toBeTruthy(); }); test('sendEmail: true triggers SMTP send when configured', async () => { // Build a SECOND app instance where emailConfig is a real-looking object, // so isConfigured() returns true. The first app uses emailConfig=null. mockEmailSender.isConfigured.mockReturnValueOnce(true); mockEmailSender.sendEmail.mockResolvedValueOnce(undefined); const app2 = express(); app2.use(express.json()); app2.use((req, _res, next) => { req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' }; req.app.locals = req.app.locals || {}; req.app.locals.siteConfig = {}; req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' }; next(); }); const { createUserStore } = require('../src/security/user-store'); const userStore2 = createUserStore({ dataDir: dir }); await userStore2.login({ email: 'admin@sami-host.me' }); const router2 = require('../routes/auth/admin')({ asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), errorResponse: (_res, code, msg) => ({ status: code, msg }), log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} }, session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} }, dataDir: dir, }); app2.use('/api/v1/auth', router2); const res = await request(app2) .post('/api/v1/auth/admin/invites') .send({ email: 'friend@example.com', role: 'viewer', sendEmail: true }); expect(res.status).toBe(200); expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1); const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0]; expect(to).toBe('friend@example.com'); expect(subject).toMatch(/invited/i); expect(text).toContain(res.body.acceptUrl); expect(html).toContain(res.body.acceptUrl); expect(res.body.deliveredVia).toBe('email'); }); test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => { mockEmailSender.isConfigured.mockReturnValueOnce(false); const res = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'friend@example.com', role: 'operator', sendEmail: true }); expect(res.status).toBe(200); expect(mockEmailSender.sendEmail).not.toHaveBeenCalled(); expect(res.body.deliveredVia).toBe('failed'); // acceptUrl + shareText still present so the operator can share manually. expect(res.body.acceptUrl).toBeTruthy(); expect(res.body.shareText).toBeTruthy(); // Token does NOT appear in any log call. const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1]; const tokenLeaked = logCalls.some(c => typeof c.msg === 'string' && c.msg.includes(token) ); expect(tokenLeaked).toBe(false); }); test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => { // DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'` // silently substitutes default rather than throwing. This test pins that // behavior so a future "strict role validation" change is a deliberate // decision, not a silent regression. const res = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'a@x.com', role: 'superuser' }); expect(res.status).toBe(200); expect(res.body.role).toBe('operator'); expect(mockEmailSender.sendEmail).not.toHaveBeenCalled(); }); test('email validation: missing email still rejected', async () => { const res = await request(app) .post('/api/v1/auth/admin/invites') .send({ role: 'operator' }); expect(res.status).toBe(400); expect(mockEmailSender.sendEmail).not.toHaveBeenCalled(); }); test('ttlHours: 1 still produces shareText with correct expiry wording', async () => { const res = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'a@x.com', ttlHours: 1 }); expect(res.body.shareText).toContain('expires in 1h'); }); test('DC-089: SMTP-unconfigured warn log masks the invite email (no raw PII)', async () => { mockEmailSender.isConfigured.mockReturnValueOnce(false); const res = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'friend@example.com', role: 'operator', sendEmail: true }); expect(res.status).toBe(200); expect(res.body.deliveredVia).toBe('failed'); const warn = logCalls.find(c => c.level === 'warn' && c.topic === 'auth-invite-send' ); expect(warn).toBeDefined(); // The raw address must not appear; the masked form must. expect(JSON.stringify(warn.meta)).not.toContain('friend@example.com'); expect(warn.meta.email).toBe('fr****@example.com'); }); test('DC-089: invite-accepted info log masks the created user email (no raw PII)', async () => { // Pre-authorize the email (POST /admin/users) so userStore.login doesn't // reject with not_authorized — bootstrap already happened in beforeEach. const preauth = await request(app) .post('/api/v1/auth/admin/users') .send({ email: 'newfriend@example.com' }); expect(preauth.status).toBe(200); const issue = await request(app) .post('/api/v1/auth/admin/invites') .send({ email: 'newfriend@example.com', role: 'viewer' }); expect(issue.status).toBe(200); const token = issue.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1]; const res = await request(app) .post(`/api/v1/auth/invites/${token}/accept`) .send({}); expect(res.status).toBe(200); const info = logCalls.find(c => c.level === 'info' && c.msg === 'invite accepted, user created' ); expect(info).toBeDefined(); expect(JSON.stringify(info.meta)).not.toContain('newfriend@example.com'); expect(info.meta.email).toBe('ne****@example.com'); }); });