import assert from 'node:assert/strict'; import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { generateKeyPairSync } from 'node:crypto'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const SECONDS_PER_DAY = 86400; function listen(server) { return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { resolve(server.address().port); }); }); } function request(port, method, pathname, { body } = {}) { return new Promise((resolve, reject) => { const headers = { 'Content-Type': 'application/json' }; const req = http.request( { hostname: '127.0.0.1', port, method, path: pathname, headers }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, body: raw ? JSON.parse(raw) : null }); }); }, ); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } async function makeServer(dataPath) { process.env.SKIP_LICENSE_LISTEN = '1'; const { createServer } = await import('../src/server.mjs'); const { privateKey } = generateKeyPairSync('ed25519'); const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); process.env.DND_LICENSE_DATA_PATH = dataPath; return createServer({ privateKeyPem: pem, adminToken: 'test-admin' }); } function writeData(dataPath, productKeys, activations = {}) { fs.writeFileSync( dataPath, JSON.stringify({ productKeys, revokedSubs: [], revokedKeys: [], activations, }), ); } function tokenPayload(token) { return JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')); } void test('POST /v1/activate fixed key sets exp from expiresAtSec', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); const dataPath = path.join(tmp, 'data.json'); writeData(dataPath, [ { key: 'TTRPG-FIXED', sub: 'lic_fixed', pid: 'dnd_player', maxDevices: 2, expiresAtSec: 1893456000, }, ]); const server = await makeServer(dataPath); const port = await listen(server); try { const res = await request(port, 'POST', '/v1/activate', { body: { productKey: 'TTRPG-FIXED', deviceId: 'dev-a' }, }); assert.equal(res.status, 200); assert.equal(tokenPayload(res.body.token).exp, 1893456000); } finally { server.close(); fs.rmSync(tmp, { recursive: true, force: true }); } }); void test('POST /v1/activate period key starts countdown on first activation', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); const dataPath = path.join(tmp, 'data.json'); writeData(dataPath, [ { key: 'TTRPG-PERIOD', sub: 'lic_period', pid: 'dnd_player', maxDevices: 2, validDays: 30, }, ]); const server = await makeServer(dataPath); const port = await listen(server); try { const before = Math.floor(Date.now() / 1000); const res = await request(port, 'POST', '/v1/activate', { body: { productKey: 'TTRPG-PERIOD', deviceId: 'dev-a' }, }); const after = Math.floor(Date.now() / 1000); assert.equal(res.status, 200); const payload = tokenPayload(res.body.token); assert.ok(payload.exp >= before + 30 * SECONDS_PER_DAY); assert.ok(payload.exp <= after + 30 * SECONDS_PER_DAY); const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); const pk = data.productKeys[0]; assert.ok(pk.activatedAtSec >= before); assert.equal(pk.licenseExpSec, payload.exp); } finally { server.close(); fs.rmSync(tmp, { recursive: true, force: true }); } }); void test('POST /v1/activate period key re-activation keeps same exp', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); const dataPath = path.join(tmp, 'data.json'); const licenseExpSec = 1700000000 + 365 * SECONDS_PER_DAY; writeData( dataPath, [ { key: 'TTRPG-PERIOD', sub: 'lic_period', pid: 'dnd_player', maxDevices: 2, validDays: 365, activatedAtSec: 1700000000, licenseExpSec, }, ], { lic_period: ['dev-a'] }, ); const server = await makeServer(dataPath); const port = await listen(server); try { const res = await request(port, 'POST', '/v1/activate', { body: { productKey: 'TTRPG-PERIOD', deviceId: 'dev-a' }, }); assert.equal(res.status, 200); assert.equal(tokenPayload(res.body.token).exp, licenseExpSec); } finally { server.close(); fs.rmSync(tmp, { recursive: true, force: true }); } }); void test('POST /v1/activate period key second device shares license exp', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); const dataPath = path.join(tmp, 'data.json'); const licenseExpSec = 1700000000 + 90 * SECONDS_PER_DAY; writeData( dataPath, [ { key: 'TTRPG-PERIOD', sub: 'lic_period', pid: 'dnd_player', maxDevices: 2, validDays: 90, activatedAtSec: 1700000000, licenseExpSec, }, ], { lic_period: ['dev-a'] }, ); const server = await makeServer(dataPath); const port = await listen(server); try { const res = await request(port, 'POST', '/v1/activate', { body: { productKey: 'TTRPG-PERIOD', deviceId: 'dev-b' }, }); assert.equal(res.status, 200); const payload = tokenPayload(res.body.token); assert.equal(payload.exp, licenseExpSec); assert.equal(payload.did, 'dev-b'); } finally { server.close(); fs.rmSync(tmp, { recursive: true, force: true }); } });