Round-trip cleanup of dashcaddy-api/license-keygen.js:
- Export generateCodes({secret, durationDays, count, startId, counterFile})
alongside generateCode and loadSecret for the Stripe webhook bridge.
- Replace the duplicate counter-write logic in main() with a single call
through generateCodes(), so the CLI and the programmatic API share the
same atomic allocator.
- _atomicWriteCounter() writes a uniquely-named .tmp file (pid+ts+rand
suffix) and renames over the destination. POSIX rename is atomic on the
same filesystem; the .tmp suffix prevents collisions across the event
loop. Stale .tmp files are unlinked if rename fails.
- Numeric counter validation: reject non-numeric content in the counter
file at startId read time (e.g. operator mucked up the file by hand).
- startId range-check: 0..0xFFFFFFFF, non-integer values rejected with a
clear error. Uses Object.prototype.hasOwnProperty.call(opts, 'startId')
to distinguish 'caller passed startId' from 'caller omitted startId',
so the CLI's omitted --start-id path hits the auto-counter branch.
- 32-bit codeId overflow check: startId + count - 1 must fit.
- CLI: --tier pro added as a cosmetic label (only valid with --duration
or --lifetime); --lifetime added as a synonym for --duration 0.
--lifetime and --duration are mutually exclusive. --start-id override
skips the counter write.
- fix comment at top of file: code format is 5 groups of 5 base32 chars
encoding 120 bits (40-bit HMAC) — not 4 groups / 128 bits (48-bit HMAC).
- Add __tests__/license-keygen.test.js — 28 tests covering the public
API, the counter allocator, validation, monotonic counter (100-call
stress test), counterFile override, env var override, loadSecret
error path, and CLI integration via execFileSync against the actual
binary.
461 lines
18 KiB
JavaScript
461 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);
|
|
}
|
|
|
|
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/);
|
|
});
|
|
});
|