merge dc/DC-142 into p7-wave1

This commit is contained in:
Hermes committed 2026-10-03 21:34:22 -07:00
commit f7c5ff1d0b
2 files changed
+572

No files matched your search

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