/** * 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); } test('omitted --start-id uses the auto-counter path (CLI integration)', () => { _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, }); const codes1 = JSON.parse(out1.split('Generated')[0]); expect(codes1).toHaveLength(1); expect(codes1[0].codeId).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, }); const codes2 = JSON.parse(out2.split('Generated')[0]); expect(codes2[0].codeId).toBe(2); expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('2'); }); test('--start-id override skips counter file update (CLI integration)', () => { _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, }); const codes = JSON.parse(out.split('Generated')[0]); expect(codes.map(c => c.codeId)).toEqual([500, 501]); // Counter file untouched. expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('99'); }); test('--lifetime and --duration are mutually exclusive (CLI integration)', () => { _setupSecret(); expect(() => _runCli(['--duration', '30', '--lifetime', '--count', '1'], { LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'), }), ).toThrow(/mutually exclusive/); }); test('--tier pro without --duration or --lifetime still requires one of them', () => { _setupSecret(); expect(() => _runCli(['--tier', 'pro', '--count', '1'], { LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'), }), ).toThrow(/--duration is required/); }); });