Flip POST /api/v1/auth/admin/invites default to no email; always return the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/ Discord/paste-in-email. Email becomes an opt-in checkbox (was the default). Add shareText field with pre-formatted message for one-tap paste. Stop logging raw invite URLs to error.log when SMTP is unconfigured (was just a dev fallback — link is now in the response). Frontend flips the checkbox default to unchecked and renders shareText + native share sheet button (navigator.share) alongside the raw copy-link button. 9 new tests covering default-no-send, link-always-returned, shareText-shape, opt-in SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
240 lines
9.8 KiB
JavaScript
240 lines
9.8 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
}); |