Files
dashcaddy/dashcaddy-api/__tests__/license-keygen.test.js
T
Hermes 9b9711bf24
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-057: close checkout-to-license contract drift (grade B)
Canonical product catalog at src/billing/catalog.js shared by Stripe
Checkout client (src/billing/stripe-client.js), webhook bridge
(scripts/stripe-license-bridge.js), and pricing page
(status/pricing/index.html). One-time payment keyed by productId at
$20/$50/$70/$99 — no more monthly/annual subscription drift.

Bridge resolves duration via metadata.productId (single contract),
requires payment_status === 'paid' before fulfillment (rejects
unpaid/no_payment_required/missing with ack 200), handles
async_payment_succeeded for ACH/SEPA delayed-payment flow. License
persisted to fulfillment-store BEFORE email — SMTP failure path serves
the persisted code via the new /api/v1/billing/lookup/:sessionId
endpoint (the documented customer recovery path).

Layer-1 (event-id) + layer-2 (session-id) idempotency prevent
duplicate issuance. Checkout return URLs derived from
STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) —
closes host-header-poisoning + session-ID-leak attack class.

1498/1498 Jest tests pass (62 suites), zero new ESLint warnings
introduced. Test files:
  - stripe-license-bridge.test.js (24 tests)
  - billing-lookup.test.js (8 tests, HTTP-level)
  - bridge-lookup-http.test.js (5 tests, uses exported createServer)
  - pricing-page-catalog.test.js (9 tests, per-tier consistency)
  - checkout-origin.test.js (6 tests, host injection rejection)
  - stripe-client.test.js (rewrite for productId + mode:payment)

Bridge code refactored: handleWebhook decomposed into verifySignature +
parseEventBody + checkEventIdempotency + fulfillCheckout +
ensureLicensePersisted (under ESLint complexity=20 cap). New
createServer()/createRequestHandler() factories guarded by
require.main === module.

Removed 3 stale test files from the rolled-back DC-055 attempt.
2026-08-04 14:18:49 -07:00

470 lines
18 KiB
JavaScript

/**
* Tests for dashcaddy-api/license-keygen.js
*
* Covers the programmatic API used by the Stripe webhook bridge and the
* on-disk counter allocator. The CLI path is exercised through the
* dedicated CLI regression describe block at the bottom of this file.
*
* - module.exports shape: verifyCode, parseCode, generateCode,
* generateCodes, loadSecret, VALID_DURATIONS, VERSION
* - generateCodes() validation: secret, duration, count
* - generateCodes() counter allocator: init, increment, override via
* startId, override via counterFile, atomic .tmp shape
* - generateCodes() monotonic counter: 100-call ordering, range checks
* - loadSecret() success and missing-file error
* - generateCode() round-trip: codes verify back via verifyCode()
* - CLI integration: omitted --start-id uses auto-counter, explicit
* --start-id skips counter write, --lifetime/--duration mutual exclusion
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const keygen = require('../license-keygen');
const {
verifyCode,
parseCode,
generateCode,
generateCodes,
loadSecret,
VALID_DURATIONS,
VERSION,
} = keygen;
function _tmpDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), `dashcaddy-${prefix}-`));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
}
const TEST_SECRET = 'a'.repeat(64); // 32 bytes hex
// ── Public surface ──────────────────────────────────────────────────────────
describe('license-keygen: module.exports', () => {
test('exports verifyCode, parseCode, generateCode, generateCodes, loadSecret, VALID_DURATIONS, VERSION', () => {
expect(typeof verifyCode).toBe('function');
expect(typeof parseCode).toBe('function');
expect(typeof generateCode).toBe('function');
expect(typeof generateCodes).toBe('function');
expect(typeof loadSecret).toBe('function');
expect(Array.isArray(VALID_DURATIONS)).toBe(true);
expect(VALID_DURATIONS).toEqual([30, 90, 180, 365]);
expect(VERSION).toBe(1);
});
});
// ── generateCode / parseCode / verifyCode round-trip ────────────────────────
describe('license-keygen: generateCode round-trip', () => {
test('generated code verifies back via verifyCode()', () => {
const code = generateCode(TEST_SECRET, 90, 42);
expect(code).toMatch(/^DC-([0-9A-Z]{5})(-[0-9A-Z]{5}){4}$/);
const result = verifyCode(TEST_SECRET, code);
expect(result.valid).toBe(true);
expect(result.durationDays).toBe(90);
expect(result.codeId).toBe(42);
});
test('verifyCode rejects a code from a different secret', () => {
const code = generateCode(TEST_SECRET, 30, 1);
const result = verifyCode('b'.repeat(64), code);
expect(result.valid).toBe(false);
expect(result.reason).toMatch(/signature/i);
});
test('parseCode returns version, duration, codeId, timestamp', () => {
const code = generateCode(TEST_SECRET, 365, 9999);
const parsed = parseCode(code);
expect(parsed.version).toBe(VERSION);
expect(parsed.durationDays).toBe(365);
expect(parsed.codeId).toBe(9999);
expect(typeof parsed.createdTs).toBe('number');
});
});
// ── generateCodes: validation ───────────────────────────────────────────────
describe('license-keygen: generateCodes validation', () => {
test('throws on missing secret', () => {
expect(() => generateCodes({ secret: '', durationDays: 30 })).toThrow(/secret is required/);
expect(() => generateCodes({ secret: 123, durationDays: 30 })).toThrow(/secret is required/);
expect(() => generateCodes({ durationDays: 30 })).toThrow(/secret is required/);
});
test('throws on invalid duration', () => {
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 7 })).toThrow(/invalid duration/);
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 31 })).toThrow(/invalid duration/);
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: -1 })).toThrow(/invalid duration/);
});
test('accepts LIFETIME (durationDays: 0)', () => {
const tmp = _tmpDir('kg-lifetime');
try {
const codes = generateCodes({
secret: TEST_SECRET,
durationDays: 0,
counterFile: path.join(tmp, '.counter'),
});
expect(codes).toHaveLength(1);
expect(codes[0].durationDays).toBe(0);
} finally { _cleanup(tmp); }
});
test('throws on invalid count', () => {
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 0 })).toThrow(/invalid count/);
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: -1 })).toThrow(/invalid count/);
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 10001 })).toThrow(/invalid count/);
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 1.5 })).toThrow(/invalid count/);
});
});
// ── generateCodes: counter allocator ────────────────────────────────────────
describe('license-keygen: generateCodes counter', () => {
let tmp;
beforeEach(() => { tmp = _tmpDir('kg-counter'); });
afterEach(() => { _cleanup(tmp); });
test('initializes counter at 1 when file is missing', () => {
const codes = generateCodes({
secret: TEST_SECRET,
durationDays: 30,
counterFile: path.join(tmp, '.counter'),
});
expect(codes[0].codeId).toBe(1);
expect(fs.readFileSync(path.join(tmp, '.counter'), 'utf8').trim()).toBe('1');
});
test('increments counter on subsequent calls', () => {
const counterFile = path.join(tmp, '.counter');
for (let i = 1; i <= 3; i++) {
const codes = generateCodes({
secret: TEST_SECRET,
durationDays: 30,
counterFile,
});
expect(codes[0].codeId).toBe(i);
}
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('3');
});
test('respects startId override and does NOT touch the counter file', () => {
const counterFile = path.join(tmp, '.counter');
fs.writeFileSync(counterFile, '100');
const codes = generateCodes({
secret: TEST_SECRET,
durationDays: 30,
count: 3,
startId: 500,
counterFile,
});
expect(codes.map(c => c.codeId)).toEqual([500, 501, 502]);
// Counter file unchanged — overrideStartId path skips the write.
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100');
});
test('no leftover .tmp files after a successful call', () => {
const counterFile = path.join(tmp, '.counter');
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
const entries = fs.readdirSync(tmp);
expect(entries.filter(e => e.includes('.tmp'))).toEqual([]);
});
test('counter file uses per-call unique tmp suffix (no .tmp collisions)', () => {
const counterFile = path.join(tmp, '.counter');
const origWrite = fs.writeFileSync;
const tmpNames = [];
fs.writeFileSync = (p, data, opts) => {
if (typeof p === 'string' && p.startsWith(counterFile) && p.includes('.tmp')) {
tmpNames.push(p);
}
return origWrite.call(fs, p, data, opts);
};
try {
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
expect(tmpNames).toHaveLength(2);
expect(new Set(tmpNames).size).toBe(2);
} finally {
fs.writeFileSync = origWrite;
}
});
});
// ── generateCodes: monotonic counter ────────────────────────────────────────
//
// generateCodes() is synchronous. Node's single-threaded event loop means
// two synchronous calls cannot interleave, so the counter is monotonically
// incremented without any explicit locking. The atomic write helper
// protects against process crashes between writeFileSync and renameSync.
// These tests verify that ordering and atomicity hold across many calls.
describe('license-keygen: generateCodes monotonic counter', () => {
let tmp;
beforeEach(() => { tmp = _tmpDir('kg-mono'); });
afterEach(() => { _cleanup(tmp); });
test('100 sequential calls produce 100 unique codeIds in monotonic order', () => {
const counterFile = path.join(tmp, '.counter');
const codes = [];
for (let i = 0; i < 100; i++) {
codes.push(generateCodes({
secret: TEST_SECRET,
durationDays: 30,
counterFile,
})[0]);
}
const ids = codes.map(c => c.codeId);
expect(ids).toHaveLength(100);
expect(new Set(ids).size).toBe(100);
for (let i = 1; i < ids.length; i++) {
expect(ids[i]).toBe(ids[i - 1] + 1);
}
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100');
});
test('100 sequential calls each requesting 5 codes produce 500 unique IDs', () => {
const counterFile = path.join(tmp, '.counter');
const batches = [];
for (let i = 0; i < 100; i++) {
batches.push(generateCodes({
secret: TEST_SECRET,
durationDays: 30,
count: 5,
counterFile,
}));
}
const allIds = batches.flat().map(c => c.codeId);
expect(allIds).toHaveLength(500);
expect(new Set(allIds).size).toBe(500);
batches.forEach((batch, i) => {
const start = i * 5 + 1;
expect(batch.map(c => c.codeId)).toEqual([start, start + 1, start + 2, start + 3, start + 4]);
});
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('500');
});
test('startId override is range-checked (negative throws)', () => {
expect(() => generateCodes({
secret: TEST_SECRET,
durationDays: 30,
startId: -1,
counterFile: path.join(tmp, '.counter'),
})).toThrow(/out of range/);
});
test('startId override is range-checked (over 32-bit throws)', () => {
expect(() => generateCodes({
secret: TEST_SECRET,
durationDays: 30,
startId: 0x100000000,
counterFile: path.join(tmp, '.counter'),
})).toThrow(/out of range/);
});
test('startId override is rejected for non-integer values', () => {
// Codex round 2: Number.isInteger(overrideStartId) returned false for
// floats/NaN/null/strings, silently falling through to auto-counter.
// The Object.prototype.hasOwnProperty check above fixes the dispatch.
const counterFile = path.join(tmp, '.counter');
fs.writeFileSync(counterFile, '99');
for (const bad of [1.5, NaN, null, '100', undefined, false]) {
const prevValue = fs.readFileSync(counterFile, 'utf8').trim();
expect(() => generateCodes({
secret: TEST_SECRET,
durationDays: 30,
startId: bad,
counterFile,
})).toThrow(/out of range|non-integer/);
// Counter file must NOT be touched when the call throws.
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe(prevValue);
}
});
test('count that would push codeId past 32-bit throws', () => {
const counterFile = path.join(tmp, '.counter');
fs.writeFileSync(counterFile, String(0xFFFFFFFF - 5));
expect(() => generateCodes({
secret: TEST_SECRET,
durationDays: 30,
count: 10,
counterFile,
})).toThrow(/32-bit limit/);
});
});
// ── generateCodes: counterFile override ─────────────────────────────────────
describe('license-keygen: generateCodes counterFile override', () => {
let tmp;
beforeEach(() => { tmp = _tmpDir('kg-cf'); });
afterEach(() => { _cleanup(tmp); });
test('counterFile option overrides LICENSE_COUNTER_FILE env', () => {
const cf = path.join(tmp, '.counter');
const prev = process.env.LICENSE_COUNTER_FILE;
try {
process.env.LICENSE_COUNTER_FILE = path.join(tmp, 'env-counter');
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile: cf });
expect(fs.existsSync(cf)).toBe(true);
expect(fs.existsSync(path.join(tmp, 'env-counter'))).toBe(false);
} finally {
if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE;
else process.env.LICENSE_COUNTER_FILE = prev;
}
});
test('LICENSE_COUNTER_FILE env overrides the default __dirname counter', () => {
const tmpForEnv = _tmpDir('kg-env');
try {
const target = path.join(tmpForEnv, 'env-counter');
const prev = process.env.LICENSE_COUNTER_FILE;
process.env.LICENSE_COUNTER_FILE = target;
try {
const codes = generateCodes({ secret: TEST_SECRET, durationDays: 30 });
expect(codes[0].codeId).toBeLessThanOrEqual(1); // fresh env
expect(fs.existsSync(target)).toBe(true);
} finally {
if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE;
else process.env.LICENSE_COUNTER_FILE = prev;
}
} finally { _cleanup(tmpForEnv); }
});
});
// ── loadSecret ──────────────────────────────────────────────────────────────
describe('license-keygen: loadSecret', () => {
let tmp;
beforeEach(() => { tmp = _tmpDir('kg-secret'); });
afterEach(() => { _cleanup(tmp); });
test('returns trimmed contents of an existing secret file', () => {
const file = path.join(tmp, '.license-secret');
fs.writeFileSync(file, ' abc123 \n');
expect(loadSecret(file)).toBe('abc123');
});
test('throws on missing file with helpful message', () => {
const file = path.join(tmp, 'does-not-exist');
expect(() => loadSecret(file)).toThrow(/not found/i);
expect(() => loadSecret(file)).toThrow(/--init-secret/i);
});
});
// ── generateCodes: failure modes ────────────────────────────────────────────
describe('license-keygen: generateCodes failure modes', () => {
let tmp;
beforeEach(() => { tmp = _tmpDir('kg-fail'); });
afterEach(() => { _cleanup(tmp); });
test('throws when counter file exists but contains non-numeric data', () => {
const counterFile = path.join(tmp, '.counter');
fs.writeFileSync(counterFile, 'not-a-number');
expect(() =>
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile }),
).toThrow(/non-numeric/);
});
});
// ── CLI regression: spawn the real binary and verify argument handling ───────
//
// Codex round 4 caught a regression: main() always passed
// `startId: overrideStartId` to generateCodes(), even when --start-id was
// omitted. The new hasOwnProperty-based validation then rejected the call
// because startId was an explicit (undefined) value. The fix is to omit
// the startId property from the options object when --start-id is absent.
// These tests exercise the actual CLI binary to make sure the local fix
// wires up correctly.
const KEYGEN_BIN = path.resolve(__dirname, '..', 'license-keygen.js');
function _runCli(args, env) {
return execFileSync('node', [KEYGEN_BIN, ...args], {
env: { ...process.env, ...env },
encoding: 'utf8',
});
}
describe('license-keygen: CLI regression', () => {
let tmp;
beforeEach(() => { tmp = _tmpDir('kg-cli'); });
afterEach(() => { _cleanup(tmp); });
function _setupSecret() {
fs.writeFileSync(path.join(tmp, '.license-secret'), TEST_SECRET);
return path.join(tmp, '.license-secret');
}
test('omitted --start-id uses the auto-counter path (CLI integration)', () => {
const secretFile = _setupSecret();
const counterFile = path.join(tmp, '.license-counter');
// First call: no --start-id, expects counter to be created at 1.
const out1 = _runCli(['--duration', '30', '--count', '1', '--json'], {
LICENSE_COUNTER_FILE: counterFile,
LICENSE_SECRET_FILE: secretFile,
});
const codes1 = JSON.parse(out1.split('Generated')[0]);
expect(codes1.length).toBe(1);
expect(codes1[0].durationDays).toBe(30);
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('1');
// Second call: counter should auto-increment to 2.
const out2 = _runCli(['--duration', '30', '--count', '1', '--json'], {
LICENSE_COUNTER_FILE: counterFile,
LICENSE_SECRET_FILE: secretFile,
});
const codes2 = JSON.parse(out2.split('Generated')[0]);
expect(codes2[0].codeId).toBeGreaterThan(codes1[0].codeId);
});
test('--start-id override skips counter file update (CLI integration)', () => {
const secretFile = _setupSecret();
const counterFile = path.join(tmp, '.license-counter');
fs.writeFileSync(counterFile, '99');
const out = _runCli(['--duration', '30', '--start-id', '500', '--count', '2', '--json'], {
LICENSE_COUNTER_FILE: counterFile,
LICENSE_SECRET_FILE: secretFile,
});
const codes = JSON.parse(out.split('Generated')[0]);
expect(codes.length).toBe(2);
expect(codes[0].codeId).toBe(500);
expect(codes[1].codeId).toBe(501);
// Counter file remains untouched at '99' (override skips auto-update).
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('99');
});
test('--lifetime and --duration are mutually exclusive (CLI integration)', () => {
const secretFile = _setupSecret();
expect(() =>
_runCli(['--duration', '30', '--lifetime', '--count', '1'], {
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
LICENSE_SECRET_FILE: secretFile,
}),
).toThrow(/mutually exclusive/);
});
test('--tier pro without --duration or --lifetime still requires one of them', () => {
const secretFile = _setupSecret();
expect(() =>
_runCli(['--tier', 'pro', '--count', '1'], {
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
LICENSE_SECRET_FILE: secretFile,
}),
).toThrow(/--duration is required/);
});
});