318 lines
13 KiB
JavaScript
318 lines
13 KiB
JavaScript
/**
|
|
* 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/);
|
|
});
|
|
});
|
|
});
|