/** * Route tests for WireGuard invite redemption (DC-150). * * Coverage: * - GET serves the redemption HTML (keygen script, X25519, pkcs8 references) * - POST happy path: peer added, config returned, NO privateKey anywhere * - used / expired / unknown token → 410 Gone * - invalid publicKey (length, charset) → 400 * - single-use enforced: second POST → 410 * - deviceName validation (missing, blank, too long) → 400 * - bad inputs never burn the token (validation precedes redemption) * - token value is NEVER logged (log mock assertions) * Hermetic — supertest against an in-process express app, mocked provider. */ 'use strict'; const express = require('express'); const request = require('supertest'); const crypto = require('crypto'); const redeemRoutes = require('../routes/network-wg-redeem'); const TOKEN = 'tok_' + 'a'.repeat(20); const PUB_KEY = Buffer.alloc(32, 7).toString('base64'); // 44 chars, '=' pad function makeProvider({ failRedeem, failAddPeerCode } = {}) { const calls = { redeemInvite: [], addPeer: [] }; let used = false; return { calls, // eslint-disable-next-line require-await async redeemInvite(token) { calls.redeemInvite.push(token); if (failRedeem) { const err = new Error(typeof failRedeem === 'string' ? failRedeem : 'redeemInvite: token expired'); err.code = 'not_found'; throw err; } if (used) { const err = new Error('redeemInvite: token already used'); err.code = 'bad_input'; throw err; } used = true; return { role: 'member', email: 'newbie@example.com' }; }, // eslint-disable-next-line require-await async addPeer(args) { calls.addPeer.push(args); if (failAddPeerCode) { const err = new Error('addPeer: rejected'); err.code = failAddPeerCode; throw err; } return { deviceId: args.deviceId, publicKey: args.publicKey, address: '10.53.0.2/32', allowedIps: '10.53.0.0/16', dns: '10.53.0.1', hubPublicKey: 'HUB' + 'P'.repeat(39) + '=', hubAddress: '10.53.0.1/16', listenPort: 51820, }; }, }; } function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } function createApp(opts = {}) { const endpoint = 'endpoint' in opts ? opts.endpoint : 'hub.example.com:51820'; const provider = opts.provider || makeProvider(); const logCalls = []; const logger = opts.log || { info: (...a) => logCalls.push(['info', a]), warn: (...a) => logCalls.push(['warn', a]), error: (...a) => logCalls.push(['error', a]), }; const router = redeemRoutes({ provider, endpoint, asyncHandler, log: logger, }); const app = express(); app.use(express.json()); app.use('/network/wg/redeem', 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, logCalls }; } afterEach(() => { jest.restoreAllMocks(); }); describe('DC-150 WireGuard invite redemption routes', () => { describe('GET /network/wg/redeem/:token (redemption page)', () => { it('serves HTML containing the browser-keygen script with X25519 + pkcs8 references', async () => { const { app } = createApp(); const res = await request(app).get('/network/wg/redeem/' + TOKEN); expect(res.status).toBe(200); expect(res.headers['content-type']).toMatch(/text\/html/); expect(res.headers['cache-control']).toBe('no-store'); expect(res.text).toContain('crypto.subtle.generateKey'); expect(res.text).toContain('X25519'); expect(res.text).toContain('pkcs8'); expect(res.text).toContain('exportKey'); // fixed PKCS#8 prefix bytes (30 2e 02 01 00 30 05 06 03 2b 65 6e 04 22 04 20) expect(res.text).toContain('0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x6e,0x04,0x22,0x04,0x20'); // private key warning + client-side QR expect(res.text).toMatch(/save this config now/i); expect(res.text).toContain('QRCode'); }); it('serves the same page for any token (validation happens at POST)', async () => { const { app } = createApp(); const res = await request(app).get('/network/wg/redeem/anything-at-all'); expect(res.status).toBe(200); expect(res.text).toContain('X25519'); }); }); describe('POST /network/wg/redeem/:token (happy path)', () => { it('adds a peer and returns the client config with NO private key anywhere', async () => { const provider = makeProvider(); const { app, logCalls } = createApp({ provider }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'sam-laptop', publicKey: PUB_KEY }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.deviceName).toBe('sam-laptop'); expect(res.body.config).toEqual({ address: '10.53.0.2/32', dns: '10.53.0.1', hubPublicKey: 'HUB' + 'P'.repeat(39) + '=', allowedIps: '10.53.0.0/16', endpoint: 'hub.example.com:51820', }); // NO privateKey field at any depth; no /private/i anywhere in the body. expect(JSON.stringify(res.body)).not.toMatch(/private/i); // provider got ONLY the public key expect(provider.calls.addPeer).toHaveLength(1); const addArgs = provider.calls.addPeer[0]; expect(addArgs.publicKey).toBe(PUB_KEY); expect(Object.keys(addArgs)).not.toContain('privateKey'); expect(addArgs.personId).toBeTruthy(); expect(addArgs.deviceId).toMatch(/^wgd_[0-9a-f]{16}$/); expect(addArgs.name).toBe('sam-laptop'); // log mock NEVER receives the token value const flat = JSON.stringify(logCalls); expect(flat).not.toContain(TOKEN); }); it('falls back to request host + listenPort when no endpoint injected', async () => { const { app } = createApp({ endpoint: undefined }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .set('Host', 'box.internal:8443') .send({ deviceName: 'phone', publicKey: PUB_KEY }); expect(res.status).toBe(200); expect(res.body.config.endpoint).toBe('box.internal:51820'); }); it('defaults dns and allowedIps per spec when provider omits them', async () => { const provider = { // eslint-disable-next-line require-await redeemInvite: async () => ({ role: 'member', email: null }), // eslint-disable-next-line require-await addPeer: async () => ({ address: '10.53.0.9/32', hubPublicKey: 'H', listenPort: 51821 }), }; const { app } = createApp({ provider }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'x', publicKey: PUB_KEY }); expect(res.body.config.dns).toBe('10.53.0.1'); expect(res.body.config.allowedIps).toBe('10.53.0.0/16'); }); }); describe('token lifecycle (410 Gone)', () => { it('unknown / expired token → 410 with a clear message', async () => { const { app, logCalls } = createApp({ provider: makeProvider({ failRedeem: true }) }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'sam-laptop', publicKey: PUB_KEY }); expect(res.status).toBe(410); expect(res.body.error).toMatch(/expired|already used/i); // never addPeer on a dead token expect(JSON.stringify(logCalls)).not.toContain(TOKEN); }); it('single-use enforced: second POST with the same token → 410', async () => { const provider = makeProvider(); const { app } = createApp({ provider }); const key2 = crypto.randomBytes(32).toString('base64'); const first = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'dev1', publicKey: PUB_KEY }); expect(first.status).toBe(200); const second = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'dev2', publicKey: key2 }); expect(second.status).toBe(410); expect(provider.calls.addPeer).toHaveLength(1); // dev2 never registered }); }); describe('publicKey validation (400)', () => { const badKeys = [ ['', 'missing'], [Buffer.alloc(31).toString('base64'), 'wrong decoded length (31 bytes)'], [Buffer.alloc(33).toString('base64'), 'wrong decoded length (33 bytes)'], ['A'.repeat(43) + '!', 'bad charset (punctuation)'], ['A'.repeat(43) + '_', 'bad charset (urlsafe char)'], [Buffer.alloc(32).toString('hex'), 'hex, not base64'], [12345, 'not a string'], ]; for (const [key, label] of badKeys) { it('rejects ' + label + ' → 400', async () => { const provider = makeProvider(); const { app } = createApp({ provider }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'sam-laptop', publicKey: key }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/publicKey/i); // bad input must NOT burn the one-time token expect(provider.calls.redeemInvite).toHaveLength(0); }); } it('accepts a real 44-char base64 key decoding to 32 bytes', async () => { const realKey = crypto.randomBytes(32).toString('base64'); // 44 chars incl '=' expect(realKey).toHaveLength(44); const { app } = createApp({}); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'ok', publicKey: realKey }); expect(res.status).toBe(200); }); }); describe('deviceName validation (400)', () => { const badNames = [ [undefined, 'missing'], [null, 'null'], ['', 'empty'], [' ', 'whitespace-only'], ['x'.repeat(101), 'over 100 chars'], [42, 'not a string'], ]; for (const [name, label] of badNames) { it('rejects ' + label + ' → 400', async () => { const provider = makeProvider(); const { app } = createApp({ provider }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: name, publicKey: PUB_KEY }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/deviceName/i); expect(provider.calls.redeemInvite).toHaveLength(0); }); } it('trims a valid name before storing', async () => { const provider = makeProvider(); const { app } = createApp({ provider }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: ' pad-book ', publicKey: PUB_KEY }); expect(res.status).toBe(200); expect(res.body.deviceName).toBe('pad-book'); expect(provider.calls.addPeer[0].name).toBe('pad-book'); }); }); describe('provider failure mapping', () => { it('addPeer bad_input → 400 (token already burned — retry impossible, surface clearly)', async () => { const { app } = createApp({ provider: makeProvider({ failAddPeerCode: 'bad_input' }) }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'dup', publicKey: PUB_KEY }); expect(res.status).toBe(400); }); it('addPeer infrastructure failure → 500 via error handler (no config leak)', async () => { const { app } = createApp({ provider: makeProvider({ failAddPeerCode: 'provider_error' }) }); const res = await request(app) .post('/network/wg/redeem/' + TOKEN) .send({ deviceName: 'dup', publicKey: PUB_KEY }); expect(res.status).toBe(500); expect(JSON.stringify(res.body)).not.toMatch(/private/i); }); }); describe('module surface', () => { it('exports the public mount prefix constant for middleware registration', () => { expect(redeemRoutes.PUBLIC_REDEEM_PATH_PREFIX).toBe('/network/wg/redeem/'); }); it('derivePersonId prefers invite personId, then email, then token hash', () => { expect(redeemRoutes.derivePersonId({ personId: 'p1' }, 't')).toBe('p1'); expect(redeemRoutes.derivePersonId({ email: 'a@b.c' }, 't')).toBe('wge:a@b.c'); expect(redeemRoutes.derivePersonId({ role: 'member' }, 'tok')).toMatch(/^wgi:[0-9a-f]{24}$/); }); it('factory throws without a provider (redeemInvite/addPeer required)', () => { expect(() => redeemRoutes({})).toThrow(/provider/); expect(() => redeemRoutes({ provider: { redeemInvite: async () => {} } })).toThrow(/provider/); }); }); });