From 88d34c6d7ec1883d439dbfdeee690e33a6895871 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 21:31:57 -0700 Subject: [PATCH] DC-142: link-first invites routes via network provider + tests --- .../__tests__/network-invites.routes.test.js | 311 ++++++++++++++++++ dashcaddy-api/routes/network-invites.js | 261 +++++++++++++++ 2 files changed, 572 insertions(+) create mode 100644 dashcaddy-api/__tests__/network-invites.routes.test.js create mode 100644 dashcaddy-api/routes/network-invites.js diff --git a/dashcaddy-api/__tests__/network-invites.routes.test.js b/dashcaddy-api/__tests__/network-invites.routes.test.js new file mode 100644 index 0000000..bd88f04 --- /dev/null +++ b/dashcaddy-api/__tests__/network-invites.routes.test.js @@ -0,0 +1,311 @@ +/** + * Route tests for network-invites routes (DC-142). + * Coverage: + * - GET / lists invites via provider; empty list when provider lacks + * listInvites (capability degradation) + * - POST / without sendEmail → deliveredVia 'manual' (default NO email) + * - POST / with sendEmail:true + emailSent → deliveredVia 'email' + * - POST / provider email failure (emailSent:false or email_send_failed + * with link on the error) → deliveredVia 'failed', link still present + * - DELETE /:id → 204; not_found maps to 404; bad_input maps to 400 + * - 503 + code provider_not_active when no provider is connected + * - no raw token / 'uinv/' ever reaches the log mock (redaction) + * Hermetic — supertest against an in-process express app, mocked provider, + * no network. + */ + +'use strict'; + +/* eslint-disable require-await -- provider mocks resolve settled values on + purpose; the route under test does the awaiting. */ +const express = require('express'); +const request = require('supertest'); +const networkInvitesRoutes = require('../routes/network-invites'); +const { ProviderError, ProviderNotActiveError } = require('../src/network/errors'); + +const INVITE_URL = 'https://login.tailscale.com/uinv/abc123secretcode'; + +function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +/** Capturing logger — every call is recorded so tests can grep for leaks. */ +function captureLog() { + const calls = []; + const mk = (level) => (...args) => calls.push({ level, args }); + return { info: mk('info'), warn: mk('warn'), error: mk('error'), calls }; +} + +function makeProvider(overrides = {}) { + const provider = { + id: () => 'mock', + listInvites: async () => [{ id: 'inv-1', inviteUrl: INVITE_URL }], + createInvite: async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-2' }), + revokeInvite: async () => ({ revoked: true }), + ...overrides, + }; + return provider; +} + +function createApp({ provider, registry } = {}) { + const log = captureLog(); + const networkRegistry = registry || { + getActiveProvider: provider + ? () => provider + : () => { throw new ProviderNotActiveError(); }, + }; + const router = networkInvitesRoutes({ networkRegistry, asyncHandler, log }); + const app = express(); + app.use(express.json()); + app.use('/network/invites', router); + app.use((err, _req, res, _next) => { + const status = err.statusCode || 500; + res.status(status).json({ success: false, error: err.message, code: err.code || null }); + }); + return { app, log }; +} + +describe('network-invites routes: contract', () => { + test('GET / lists active invites via provider', async () => { + const { app } = createApp({ provider: makeProvider() }); + const res = await request(app).get('/network/invites'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.count).toBe(1); + expect(res.body.data[0].id).toBe('inv-1'); + expect(res.body.supported).toBe(true); + }); + + test('GET / returns empty list when provider lacks listInvites', async () => { + const provider = makeProvider(); + delete provider.listInvites; + const { app } = createApp({ provider }); + const res = await request(app).get('/network/invites'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + expect(res.body.data).toEqual([]); + expect(res.body.supported).toBe(false); + }); + + test('POST / without sendEmail creates link-only invite (deliveredVia manual)', async () => { + const createInvite = jest.fn(async ({ email }) => { + expect(email).toBeUndefined(); // link-first: no email key at all + return { inviteUrl: INVITE_URL, id: 'inv-2' }; + }); + const { app } = createApp({ provider: makeProvider({ createInvite }) }); + const res = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member' }); + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.inviteUrl).toBe(INVITE_URL); + expect(res.body.deliveredVia).toBe('manual'); + expect(createInvite).toHaveBeenCalledTimes(1); + expect(createInvite).toHaveBeenCalledWith({ role: 'member' }); + }); + + test('POST / response always includes shareText — a human sentence containing the link', async () => { + const { app } = createApp({ provider: makeProvider() }); + const res = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member' }); + expect(res.status).toBe(201); + expect(typeof res.body.shareText).toBe('string'); + expect(res.body.shareText).toContain('Sami invited you to join the family network'); + expect(res.body.shareText).toContain(INVITE_URL); + // A sentence, not a JSON blob: + expect(res.body.shareText.startsWith('{')).toBe(false); + }); + + test('POST / rejects invalid bodies (400)', async () => { + const { app } = createApp({ provider: makeProvider() }); + const noName = await request(app).post('/network/invites').send({ role: 'member' }); + expect(noName.status).toBe(400); + expect(noName.body.error).toMatch(/displayName/); + + const badRole = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'superadmin' }); + expect(badRole.status).toBe(400); + expect(badRole.body.error).toMatch(/role/); + + const badEmail = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member', email: 'not-an-email' }); + expect(badEmail.status).toBe(400); + expect(badEmail.body.error).toMatch(/email/); + + const sendNoAddr = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member', sendEmail: true }); + expect(sendNoAddr.status).toBe(400); + expect(sendNoAddr.body.error).toMatch(/sendEmail/); + }); + + test('sendEmail is strict opt-in: truthy non-true values do not trigger email', async () => { + const createInvite = jest.fn(async ({ email }) => { + if (email) return { inviteUrl: INVITE_URL, id: 'inv-e', emailSent: true }; + return { inviteUrl: INVITE_URL, id: 'inv-m' }; + }); + const { app } = createApp({ provider: makeProvider({ createInvite }) }); + const res = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member', email: 'x@y.io', sendEmail: 'yes' }); + expect(res.status).toBe(201); + expect(res.body.deliveredVia).toBe('manual'); + expect(createInvite).toHaveBeenCalledWith({ role: 'member' }); + }); +}); + +describe('network-invites routes: email leg', () => { + test('POST / with sendEmail:true and provider-confirmed send → deliveredVia email', async () => { + const createInvite = jest.fn(async () => ({ + inviteUrl: INVITE_URL, + id: 'inv-3', + emailSent: true, + })); + const { app } = createApp({ provider: makeProvider({ createInvite }) }); + const res = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'admin', email: 'friend@example.com', sendEmail: true }); + expect(res.status).toBe(201); + expect(res.body.deliveredVia).toBe('email'); + expect(res.body.inviteUrl).toBe(INVITE_URL); + expect(createInvite).toHaveBeenCalledWith({ role: 'admin', email: 'friend@example.com' }); + }); + + test('provider does not confirm send (emailSent false) → deliveredVia failed, link present', async () => { + const createInvite = jest.fn(async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-4' })); + const { app } = createApp({ provider: makeProvider({ createInvite }) }); + const res = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true }); + expect(res.status).toBe(201); // not a total failure — link still returned + expect(res.body.deliveredVia).toBe('failed'); + expect(res.body.inviteUrl).toBe(INVITE_URL); + expect(res.body.shareText).toContain(INVITE_URL); + }); + + test('provider throws email_send_failed with link on the error → deliveredVia failed, link present', async () => { + const err = new ProviderError('provider email send failed', { + code: 'email_send_failed', + }); + err.inviteUrl = INVITE_URL; // provider minted the invite, email leg failed + const createInvite = jest.fn(async () => { throw err; }); + const { app } = createApp({ provider: makeProvider({ createInvite }) }); + const res = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true }); + expect(res.status).toBe(201); + expect(res.body.deliveredVia).toBe('failed'); + expect(res.body.inviteUrl).toBe(INVITE_URL); + }); +}); + +describe('network-invites routes: revoke', () => { + test('DELETE /:id returns 204 on success', async () => { + const revokeInvite = jest.fn(async (id) => { + expect(id).toBe('inv-9'); + return { revoked: true }; + }); + const { app } = createApp({ provider: makeProvider({ revokeInvite }) }); + const res = await request(app).delete('/network/invites/inv-9'); + expect(res.status).toBe(204); + expect(revokeInvite).toHaveBeenCalledWith('inv-9'); + }); + + test('DELETE maps provider not_found → 404', async () => { + const revokeInvite = async () => { + throw new ProviderError('invite not found', { code: 'not_found' }); + }; + const { app } = createApp({ provider: makeProvider({ revokeInvite }) }); + const res = await request(app).delete('/network/invites/ghost'); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + + test('DELETE maps provider bad_input → 400', async () => { + const revokeInvite = async () => { + throw new ProviderError('revokeInvite: id is required', { code: 'bad_input' }); + }; + const { app } = createApp({ provider: makeProvider({ revokeInvite }) }); + const res = await request(app).delete('/network/invites/x'); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + test('DELETE maps upstream 5xx ProviderError → 502', async () => { + const revokeInvite = async () => { + throw new ProviderError('upstream unavailable', { code: 'server_error' }); + }; + const { app } = createApp({ provider: makeProvider({ revokeInvite }) }); + const res = await request(app).delete('/network/invites/x'); + expect(res.status).toBe(502); + }); +}); + +describe('network-invites routes: provider inactive', () => { + [['GET', '/network/invites', null], + ['POST', '/network/invites', { displayName: 'Sami', role: 'member' }], + ['DELETE', '/network/invites/inv-1', null]].forEach(([method, url, body]) => { + test(`${method} returns 503 provider_not_active when no provider connected`, async () => { + const { app } = createApp({}); // registry throws ProviderNotActiveError + const req = request(app)[method.toLowerCase()](url); + if (body) req.send(body); + const res = await req; + expect(res.status).toBe(503); + expect(res.body.success).toBe(false); + expect(res.body.code).toBe('provider_not_active'); + }); + }); + + test('missing registry entirely also yields 503', async () => { + const { app } = createApp({ registry: {} }); // no getActiveProvider fn + const res = await request(app).get('/network/invites'); + expect(res.status).toBe(503); + expect(res.body.code).toBe('provider_not_active'); + }); +}); + +describe('network-invites routes: no raw tokens in logs', () => { + function assertNoLeak(log) { + expect(log.calls.length).toBeGreaterThan(0); // logging happened… + for (const { args } of log.calls) { + for (const arg of args) { + if (typeof arg !== 'string') continue; + expect(arg).not.toContain('uinv/'); // …but never the raw link + expect(arg).not.toContain('abc123secretcode'); + } + } + } + + test('create (manual) never logs the raw invite URL', async () => { + const { app, log } = createApp({ provider: makeProvider() }); + const res = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member' }); + expect(res.status).toBe(201); + assertNoLeak(log); + }); + + test('create (email failed) never logs the raw invite URL', async () => { + const createInvite = async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-f' }); + const { app, log } = createApp({ provider: makeProvider({ createInvite }) }); + const res = await request(app) + .post('/network/invites') + .send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true }); + expect(res.status).toBe(201); + expect(res.body.deliveredVia).toBe('failed'); + assertNoLeak(log); // warn fired — but with redacted fields only + }); + + test('redaction helper replaces link-bearing field values with [REDACTED]', async () => { + const { app, log } = createApp({ provider: makeProvider() }); + await request(app).post('/network/invites').send({ displayName: 'Sami', role: 'member' }); + const createCall = log.calls.find((c) => c.args[1] === 'invite created'); + expect(createCall).toBeTruthy(); + const fields = createCall.args[2]; + expect(fields.inviteUrl).toBeUndefined(); + expect(JSON.stringify(fields)).not.toContain('uinv/'); + }); +}); diff --git a/dashcaddy-api/routes/network-invites.js b/dashcaddy-api/routes/network-invites.js new file mode 100644 index 0000000..fca65f6 --- /dev/null +++ b/dashcaddy-api/routes/network-invites.js @@ -0,0 +1,261 @@ +/** + * Network invite routes — DC-142 (link-first invites via network provider). + * + * GET /api/v1/network/invites → list active invites via provider + * POST /api/v1/network/invites → create an invite (link-first) + * DELETE /api/v1/network/invites/:id → revoke an invite (204) + * + * Link-first contract (DC-085 pattern, specs/PRIVATE-NETWORK-SPEC.md §5): + * - The inviteUrl IS the deliverable. Email is an opt-in checkbox + * (`sendEmail: true`, strict === true — default NO email). + * - POST response ALWAYS includes { inviteUrl, shareText, deliveredVia }: + * shareText — pre-formed human sentence (never a JSON blob), built + * server-side so every client picks up the wording + * deliveredVia — 'manual' (no email requested) | 'email' (provider + * confirmed the send) | 'failed' (email was requested but + * the send failed — the link is STILL returned so the + * operator can share it manually) + * - NEVER log raw inviteUrl/token values. Every log call goes through + * redact() which replaces link-bearing fields with '[REDACTED]'. The DC-048 + * mistake (echoing invite links to the server console) must not return. + * + * Provider resolution goes exclusively through deps.networkRegistry + * (src/network registry). When no provider is connected the registry throws + * ProviderNotActiveError; this module maps it (and a missing registry) to a + * 503 with code 'provider_not_active' — the setup wizard (DC-152) is the fix. + * + * Hermetic by construction: the factory takes an injectable registry, logger + * and asyncHandler; tests pass a mocked provider, no network. + */ + +'use strict'; + +const { ValidationError, NotFoundError } = require('../src/utilities/errors'); +const { ok, created, noContent } = require('../src/utils/responses'); +const { ProviderError, ProviderNotActiveError } = require('../src/network/errors'); + +/** Tailscale user-invite roles (provider contract, DC-140). */ +const VALID_ROLES = new Set(['member', 'admin']); + +const REDACTED = '[REDACTED]'; +/** Fields whose values embed the raw invite link — never allowed into logs. */ +const REDACT_KEYS = new Set(['inviteUrl', 'acceptUrl', 'url', 'token', 'shareText']); + +/** + * Shallow-copy a log-fields object, replacing link/token-bearing values. + * The admin already has the link in the API response; the console does not + * need a copy (DC-085 rule 2). + */ +function redact(fields) { + const out = {}; + for (const [key, value] of Object.entries(fields || {})) { + out[key] = REDACT_KEYS.has(key) ? REDACTED : value; + } + return out; +} + +/** 503 — no network provider connected (mapped from ProviderNotActiveError). */ +class ProviderInactiveError extends Error { + constructor(message) { + super(message || 'No active network provider configured'); + this.name = 'ProviderInactiveError'; + this.statusCode = 503; + this.code = 'provider_not_active'; + this.isOperational = true; + } +} + +/** Validate the provider's createInvite result shape. */ +function assertInviteResult(invite) { + if (!invite || typeof invite.inviteUrl !== 'string' || !invite.inviteUrl) { + throw new Error('provider createInvite() must return { inviteUrl }'); + } +} + +/** Pull the still-valid link off a failed email send, or null. */ +function linkFromEmailFailure(err) { + if (!err || err.code !== 'email_send_failed') return null; + const details = err.details; + const url = err.inviteUrl || (details && details.inviteUrl); + return url ? { url, id: (details && details.id) || null } : null; +} + +module.exports = function networkInvitesRoutesFactory({ + networkRegistry, + asyncHandler, + log = { info() {}, warn() {}, error() {} }, +} = {}) { + const router = require('express').Router(); + + const safeAsync = typeof asyncHandler === 'function' + ? asyncHandler + : (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + + /** Resolve the active provider or throw the 503-mapped inactive error. */ + function requireProvider() { + if (!networkRegistry || typeof networkRegistry.getActiveProvider !== 'function') { + throw new ProviderInactiveError(); + } + try { + return networkRegistry.getActiveProvider(); + } catch (err) { + if (err instanceof ProviderNotActiveError || (err && err.code === 'provider_not_active')) { + throw new ProviderInactiveError(err.message); + } + throw err; + } + } + + /** Map ProviderError codes onto HTTP statuses for the error middleware. */ + function mapProviderError(err) { + if (err instanceof ProviderInactiveError) return err; + if (err && (err instanceof ProviderError || typeof err.code === 'string')) { + if (err.code === 'not_found') return new NotFoundError(err.message || 'invite not found'); + if (err.code === 'bad_input') return new ValidationError(err.message || 'invalid input', 'invite'); + if (err.code === 'provider_not_active') return new ProviderInactiveError(err.message); + const upstream = new Error(err.message || 'network provider error'); + upstream.statusCode = 502; + upstream.code = err.code || 'provider_error'; + upstream.isOperational = true; + return upstream; + } + return err; + } + + /** + * Email leg of the link-first flow (only entered when sendEmail === true). + * Never a total failure when the link exists: a failed send downgrades to + * deliveredVia 'failed' with the link still in the response (DC-085 rule 1). + */ + async function createInviteWithEmail(provider, { role, email }) { + let invite; + let deliveredVia; + try { + invite = await provider.createInvite({ role, email }); + assertInviteResult(invite); + // Providers confirm the email leg with emailSent: true on the result. + deliveredVia = invite.emailSent === true ? 'email' : 'failed'; + if (deliveredVia === 'failed') { + log.warn && log.warn( + 'network-invites', + 'provider did not confirm email delivery — link returned for manual sharing', + redact({ inviteId: invite.id || null, email, role }) + ); + } + return { invite, deliveredVia }; + } catch (err) { + const salvaged = linkFromEmailFailure(err); + if (salvaged) { + log.warn && log.warn( + 'network-invites', + 'provider email send failed — link returned for manual sharing', + redact({ inviteId: salvaged.id, email, role }) + ); + return { invite: { inviteUrl: salvaged.url, id: salvaged.id }, deliveredVia: 'failed' }; + } + throw mapProviderError(err); + } + } + + // ─── GET / — list active invites ──────────────────────────────────────── + router.get('/', safeAsync(async (_req, res) => { + const provider = requireProvider(); + // Capability degradation (spec §3): providers without listInvites (the + // DC-140 tailscale provider does not expose it yet) get an honest empty + // list instead of a 500. + if (typeof provider.listInvites !== 'function') { + log.info && log.info('network-invites', 'provider cannot list invites — returning empty list'); + ok(res, { data: [], count: 0, supported: false }); + return; + } + const invites = await provider.listInvites(); + if (!Array.isArray(invites)) { + throw new ValidationError('provider listInvites() must return an array', 'provider'); + } + ok(res, { data: invites, count: invites.length, supported: true }); + })); + + // ─── POST / — create an invite (link-first, email opt-in) ─────────────── + router.post('/', safeAsync(async (req, res) => { + const body = req.body || {}; + + const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''; + if (!displayName || displayName.length > 80) { + throw new ValidationError('displayName is required (1-80 characters)', 'displayName'); + } + const role = body.role; + if (!VALID_ROLES.has(role)) { + throw new ValidationError('role must be one of: ' + [...VALID_ROLES].join(', '), 'role'); + } + + // Strict opt-in: only sendEmail === true counts (never 'yes'/1/'on'). + const sendEmail = body.sendEmail === true; + + let email; + if (body.email !== undefined && body.email !== null && body.email !== '') { + if (typeof body.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { + throw new ValidationError('email must be a valid address when provided', 'email'); + } + email = body.email; + } + if (sendEmail && !email) { + throw new ValidationError('sendEmail: true requires an email address', 'sendEmail'); + } + + const provider = requireProvider(); + + let invite; + let deliveredVia; + if (sendEmail) { + ({ invite, deliveredVia } = await createInviteWithEmail(provider, { role, email })); + } else { + // Default path: no email key at all — the provider mints the link + // WITHOUT sending anything (Tailscale user-invites: email optional). + try { + invite = await provider.createInvite({ role }); + } catch (err) { + throw mapProviderError(err); + } + assertInviteResult(invite); + deliveredVia = 'manual'; + } + + // Pre-formed human sentence (never a JSON blob). Server-side canonical + // wording so every client picks it up on refresh (DC-085 rule 3). + const shareText = displayName + + ' invited you to join the family network — open this link on your device: ' + + invite.inviteUrl; + + log.info && log.info( + 'network-invites', + 'invite created', + redact({ inviteId: invite.id || null, role, deliveredVia, email }) + ); + + created(res, { + inviteUrl: invite.inviteUrl, + shareText, + deliveredVia, + id: invite.id || null, + role, + displayName, + }); + })); + + // ─── DELETE /:id — revoke an invite ───────────────────────────────────── + router.delete('/:id', safeAsync(async (req, res) => { + const id = req.params && req.params.id; + if (!id) throw new ValidationError('invite id is required', 'id'); + const provider = requireProvider(); + try { + await provider.revokeInvite(id); + } catch (err) { + throw mapProviderError(err); + } + // inviteId is provider metadata, not the token — safe to log. + log.info && log.info('network-invites', 'invite revoked', { inviteId: id }); + noContent(res); + })); + + return router; +};