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