From a468e0f48069f1eb0bd268adb76d560dfd15d445 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 12 Aug 2026 16:11:15 -0700 Subject: [PATCH] [grade=B] DC-083: comprehensive license-manager.js test coverage (77 tests, revenue path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added __tests__/license-manager.test.js with 77 tests covering the entire src/managers/license-manager.js module (534 LOC) — the revenue validation path that was previously untested by any dedicated test file. Coverage includes: - load(): credential-store primary, config-backup fallback, no-license, credential-store error → config recovery, re-store after restore - activate(): real crypto round-trip for all durations (30/90/180/365), already-activated idempotency, invalid format, missing code, offline HMAC validation failure, LIFETIME rejection (prod) + acceptance (dev), credential-store save failure, config write, lowercase normalization, whitespace trimming - activate() online path: server success, server unreachable → offline fallback, server explicit rejection (no fallback) - deactivate(): success, no-active-license, credential delete, config clear - getStatus(): free tier, active premium, expired, lifetime, code masking - hasFeature(): no-activation, active, expired, specific-feature, default - isPro()/isExpired()/daysRemaining(): all branches (no-activation, active, expired, lifetime, missing expiresAt) - getMachineFingerprint(): stable 16-char hex - requirePremium() middleware: next() on available, 403 on unavailable, upgrade URL, unknown feature - loadSecret(): file-exists, file-missing, read-error (deterministic fs mock) - _validateOffline(): with-secret valid, forged HMAC mismatch, no-secret structural-only, malformed code, unsupported version (forged v2 payload) - _updateConfig(): creates config, preserves fields, clears on deactivation, nonexistent-directory tolerance - _maskCode(): standard, short, empty - Full lifecycle: activate→status→deactivate→status, load-after-activate restore, freshly-minted-code validation Unlike license-tier-enforcement.test.js (which stubs _validateOffline), these tests exercise the REAL crypto flow end-to-end: generateCode(TEST_SECRET) → activate(code) → _validateOffline(code) → verifyCode(secret, code) → credential store. Uses jest.isolateModules for online tests so the module- level LICENSE_SERVER_URL const is re-read per test. Codex grade: B (urn:ump:vwial6vhrzzmsvpfjdxnk53hvol3wna3o2zwmneqjcquxfgdersq) Full suite: 1738/1738 pass (was 1661, +77 new). Zero new ESLint warnings on src/. --- .../__tests__/license-manager.test.js | 1490 +++++++++++++++++ 1 file changed, 1490 insertions(+) create mode 100644 dashcaddy-api/__tests__/license-manager.test.js diff --git a/dashcaddy-api/__tests__/license-manager.test.js b/dashcaddy-api/__tests__/license-manager.test.js new file mode 100644 index 0000000..7d5ad82 --- /dev/null +++ b/dashcaddy-api/__tests__/license-manager.test.js @@ -0,0 +1,1490 @@ +/** + * Comprehensive tests for src/managers/license-manager.js + * + * This is the revenue validation path — an untested bug here could silently + * break activation for every paying customer (DC-083 priority #1). + * + * Unlike license-tier-enforcement.test.js (which stubs _validateOffline and + * only tests the isPro/tier-gating surface), these tests exercise the REAL + * crypto flow end-to-end: generateCode(TEST_SECRET, ...) → activate(code) → + * _validateOffline(code) → verifyCode(secret, code) → credential store. + * + * Coverage matrix: + * - constructor + load(): credential-store primary, config-backup fallback, + * no-license, credential-store error → config recovery, re-store + * - activate(): real-code round-trip (30/90/180/365), already-activated + * idempotency, invalid format, missing code, offline-validation failure, + * LIFETIME rejection (prod) + acceptance (dev env), credential-store + * save failure, config write + * - activate() online path: server success, server unreachable → offline + * fallback, server explicit rejection (no fallback) + * - deactivate(): success, no-active-license, credential delete + * - getStatus(): free tier, active premium, expired, lifetime + * - hasFeature(): no-activation, active, expired, specific-feature + * - isPro() / isExpired() / daysRemaining(): all branches + * - getMachineFingerprint(): stable, hex format + * - requirePremium() middleware: feature-available (next), feature-unavailable (403) + * - loadSecret(): file-exists, file-missing, file-unreadable + * - _validateOffline(): with-secret, no-secret structural-only, invalid-code, + * unsupported-version (forged v2 payload) + * - _updateConfig(): writes license + backup, clears on deactivation + * - _maskCode(): standard, short, empty + * - Full lifecycle: activate → status → deactivate, load-after-activate restore + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); + +// Use the real keygen to generate cryptographically valid codes. +const { generateCode, verifyCode } = require('../license-keygen'); + +const TEST_SECRET = 'a'.repeat(64); // 32 bytes hex — deterministic test secret + +function _tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dc-lm-test-')); +} + +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ } +} + +/** + * Build a LicenseManager with a fresh module instance + env override. + * Returns { mgr, restore, dir }. + * + * @param {Object} opts + * @param {Object} opts.creds — credential store stub {store, retrieve, delete} + * @param {Object} opts.env — process.env overrides (e.g. LICENSE_SERVER_URL) + * @param {string} opts.secret — master secret to load via loadSecret() + */ +function _makeManager(opts = {}) { + const { creds = {}, env = {}, secret = null } = opts; + const prevEnv = { ...process.env }; + // Object.assign copies undefined-valued keys as the string "undefined". + // Delete env keys whose value is undefined so the production "unset" path + // is exercised accurately. + for (const [k, v] of Object.entries(env)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + + // Force re-require so LICENSE_SERVER_URL is re-read. + delete require.cache[require.resolve('../src/managers/license-manager')]; + const { LicenseManager } = require('../src/managers/license-manager'); + + const dir = _tmpDir(); + const configFile = path.join(dir, 'config.json'); + const secretFile = path.join(dir, '.license-secret'); + + const defaultCreds = { + _store: {}, + async store(key, val) { this._store[key] = val; }, + async retrieve(key) { return this._store[key] || null; }, + async delete(key) { delete this._store[key]; }, + }; + + const mgr = new LicenseManager( + creds._impl ? creds : defaultCreds, + configFile, + { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } + ); + + if (secret) { + fs.writeFileSync(secretFile, secret, 'utf8'); + mgr.loadSecret(secretFile); + } + + const restore = async () => { + process.env = prevEnv; + _cleanup(dir); + }; + + return { mgr, restore, dir, configFile, secretFile }; +} + +// ── generateCode helper: mint a real valid code for a given duration ────── + +function _mintCode(secret, durationDays, codeId = 1) { + return generateCode(secret, durationDays, codeId); +} + +// =========================================================================== +// constructor + load() +// =========================================================================== + +describe('LicenseManager: load()', () => { + test('loads active license from credential store', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + // Pre-populate the credential store with a valid activation + const code = _mintCode(TEST_SECRET, 30, 1); + const activation = { + code, + codeId: 1, + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso', 'recipes', 'swarm'], + }; + await mgr.credentialManager.store('license.activation', JSON.stringify(activation)); + + await mgr.load(); + expect(mgr.activation).toBeTruthy(); + expect(mgr.activation.code).toBe(code); + expect(mgr._loaded).toBe(true); + expect(mgr.isPro()).toBe(true); + } finally { await restore(); } + }); + + test('logs expired license on load but keeps it', async () => { + const { mgr, restore } = _makeManager(); + try { + const activation = { + code: 'DC-TEST-EXPIRED', + durationDays: 30, + lifetime: false, + activatedAt: '2020-01-01T00:00:00.000Z', + expiresAt: '2020-02-01T00:00:00.000Z', + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + await mgr.credentialManager.store('license.activation', JSON.stringify(activation)); + + await mgr.load(); + expect(mgr.activation).toBeTruthy(); + expect(mgr.isExpired()).toBe(true); + expect(mgr._loaded).toBe(true); + } finally { await restore(); } + }); + + test('falls back to config.json licenseBackup when credential store fails', async () => { + const dir = _tmpDir(); + try { + const configFile = path.join(dir, 'config.json'); + const activation = { + code: 'DC-BACKUP-TEST', + durationDays: 90, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + fs.writeFileSync(configFile, JSON.stringify({ licenseBackup: activation }, null, 2)); + + // Credential store that throws on retrieve + const failCreds = { + async retrieve() { throw new Error('encryption key changed'); }, + async store() {}, + async delete() {}, + }; + + delete require.cache[require.resolve('../src/managers/license-manager')]; + const { LicenseManager } = require('../src/managers/license-manager'); + const mgr = new LicenseManager(failCreds, configFile, { info: () => {}, warn: () => {} }); + + await mgr.load(); + expect(mgr.activation).toBeTruthy(); + expect(mgr.activation.code).toBe('DC-BACKUP-TEST'); + expect(mgr._loaded).toBe(true); + } finally { _cleanup(dir); } + }); + + test('re-stores recovered license in credential manager after config backup restore', async () => { + const dir = _tmpDir(); + try { + const configFile = path.join(dir, 'config.json'); + const activation = { + code: 'DC-RESTORE-TEST', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + fs.writeFileSync(configFile, JSON.stringify({ licenseBackup: activation }, null, 2)); + + const storeCalls = []; + const failCreds = { + async retrieve() { throw new Error('decryption failed'); }, + async store(key, val) { storeCalls.push({ key, val }); }, + async delete() {}, + }; + + delete require.cache[require.resolve('../src/managers/license-manager')]; + const { LicenseManager } = require('../src/managers/license-manager'); + const mgr = new LicenseManager(failCreds, configFile, { info: () => {}, warn: () => {} }); + + await mgr.load(); + expect(storeCalls.length).toBe(1); + expect(storeCalls[0].key).toBe('license.activation'); + expect(JSON.parse(storeCalls[0].val).code).toBe('DC-RESTORE-TEST'); + } finally { _cleanup(dir); } + }); + + test('sets activation=null when no license anywhere', async () => { + const { mgr, restore } = _makeManager(); + try { + await mgr.load(); + expect(mgr.activation).toBeNull(); + expect(mgr._loaded).toBe(true); + } finally { await restore(); } + }); + + test('sets activation=null when config.json has no licenseBackup', async () => { + const dir = _tmpDir(); + try { + const configFile = path.join(dir, 'config.json'); + fs.writeFileSync(configFile, JSON.stringify({ someOtherField: true }, null, 2)); + + const emptyCreds = { + async retrieve() { return null; }, + async store() {}, + async delete() {}, + }; + + delete require.cache[require.resolve('../src/managers/license-manager')]; + const { LicenseManager } = require('../src/managers/license-manager'); + const mgr = new LicenseManager(emptyCreds, configFile, { info: () => {} }); + + await mgr.load(); + expect(mgr.activation).toBeNull(); + } finally { _cleanup(dir); } + }); +}); + +// =========================================================================== +// activate() — the core revenue path +// =========================================================================== + +describe('LicenseManager: activate() — real crypto round-trip', () => { + test('activates a valid 30-day code via offline HMAC validation', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 100); + const result = await mgr.activate(code); + + expect(result.success).toBe(true); + expect(result.message).toMatch(/30 days/); + expect(result.activation).toBeTruthy(); + expect(result.activation.active).toBe(true); + expect(result.activation.tier).toBe('premium'); + expect(result.activation.durationDays).toBe(30); + expect(result.activation.lifetime).toBe(false); + expect(result.activation.daysRemaining).toBeGreaterThan(29); + expect(result.activation.daysRemaining).toBeLessThanOrEqual(30); + } finally { await restore(); } + }); + + test('activates valid 90, 180, and 365-day codes', async () => { + for (const duration of [90, 180, 365]) { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, duration, 200 + duration); + const result = await mgr.activate(code); + expect(result.success).toBe(true); + expect(result.activation.durationDays).toBe(duration); + expect(result.activation.daysRemaining).toBeGreaterThan(duration - 1); + } finally { await restore(); } + } + }); + + test('persists activation to credential store after successful activation', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 300); + await mgr.activate(code); + + const stored = await mgr.credentialManager.retrieve('license.activation'); + expect(stored).toBeTruthy(); + const parsed = JSON.parse(stored); + expect(parsed.code).toBe(code); + expect(parsed.durationDays).toBe(30); + } finally { await restore(); } + }); + + test('writes config.json with license info + backup after activation', async () => { + const { mgr, restore, configFile } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 90, 400); + await mgr.activate(code); + + const config = JSON.parse(fs.readFileSync(configFile, 'utf8')); + expect(config.license.active).toBe(true); + expect(config.license.tier).toBe('premium'); + expect(config.licenseBackup).toBeTruthy(); + expect(config.licenseBackup.code).toBe(code); + } finally { await restore(); } + }); + + test('idempotent: activating the same valid code twice returns already-activated', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 500); + const result1 = await mgr.activate(code); + expect(result1.success).toBe(true); + + const result2 = await mgr.activate(code); + expect(result2.success).toBe(true); + expect(result2.message).toMatch(/already activated/i); + } finally { await restore(); } + }); + + test('rejects empty/null/undefined code', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + expect((await mgr.activate('')).success).toBe(false); + expect((await mgr.activate(null)).success).toBe(false); + expect((await mgr.activate(undefined)).success).toBe(false); + expect((await mgr.activate(12345)).success).toBe(false); + } finally { await restore(); } + }); + + test('rejects code without DC- prefix', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const result = await mgr.activate('XYZ-ABCDE-FGHIJ-KLMNO-PQRST-UVWXY'); + expect(result.success).toBe(false); + expect(result.message).toMatch(/DC-/); + } finally { await restore(); } + }); + + test('rejects code with invalid HMAC signature (all-A base32, forged)', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const result = await mgr.activate('DC-AAAAA-AAAAA-AAAAA-AAAAA-AAAAA'); + // Structurally valid base32 (all 'A') parses fine but the HMAC computed + // from the decoded payload won't match — offline validation fails. + expect(result.success).toBe(false); + } finally { await restore(); } + }); + + test('rejects code with wrong HMAC signature (forged)', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + // Generate with a DIFFERENT secret, try to activate with TEST_SECRET + const wrongSecret = 'b'.repeat(64); + const forgedCode = _mintCode(wrongSecret, 30, 600); + const result = await mgr.activate(forgedCode); + expect(result.success).toBe(false); + expect(result.message).toMatch(/invalid|forged|corrupted/i); + } finally { await restore(); } + }); + + test('LIFETIME code is rejected in production (ALLOW_LIFETIME_LICENSE unset)', async () => { + const { mgr, restore } = _makeManager({ + secret: TEST_SECRET, + env: { ALLOW_LIFETIME_LICENSE: undefined }, + }); + try { + const lifetimeCode = _mintCode(TEST_SECRET, 0, 700); // durationDays=0 → lifetime + const result = await mgr.activate(lifetimeCode); + expect(result.success).toBe(false); + expect(result.message).toMatch(/lifetime/i); + expect(result.message).toMatch(/not available/i); + expect(mgr.activation).toBeNull(); + } finally { await restore(); } + }); + + test('LIFETIME code is accepted when ALLOW_LIFETIME_LICENSE=true', async () => { + const { mgr, restore } = _makeManager({ + secret: TEST_SECRET, + env: { ALLOW_LIFETIME_LICENSE: 'true' }, + }); + try { + const lifetimeCode = _mintCode(TEST_SECRET, 0, 800); + const result = await mgr.activate(lifetimeCode); + expect(result.success).toBe(true); + expect(result.activation.lifetime).toBe(true); + expect(result.activation.tier).toBe('premium'); + expect(result.activation.expiresAt).toBeNull(); + expect(result.activation.daysRemaining).toBeNull(); + expect(mgr.isPro()).toBe(true); + } finally { await restore(); } + }); + + test('returns failure when credential store throws on save', async () => { + const failCreds = { + async store() { throw new Error('disk full'); }, + async retrieve() { return null; }, + async delete() {}, + }; + const { mgr, restore } = _makeManager({ + secret: TEST_SECRET, + creds: { _impl: true, ...failCreds }, + }); + try { + const code = _mintCode(TEST_SECRET, 30, 900); + const result = await mgr.activate(code); + expect(result.success).toBe(false); + expect(result.message).toMatch(/failed to save/i); + } finally { await restore(); } + }); + + test('normalizes lowercase code to uppercase', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 1000); + const result = await mgr.activate(code.toLowerCase()); + expect(result.success).toBe(true); + expect(mgr.activation.code).toBe(code); // stored uppercase + } finally { await restore(); } + }); + + test('trims whitespace around code', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 1100); + const result = await mgr.activate(` ${code} `); + expect(result.success).toBe(true); + } finally { await restore(); } + }); +}); + +// =========================================================================== +// activate() — online validation path +// +// LICENSE_SERVER_URL is a module-level const read at require() time, so we +// use jest.isolateModules to get a fresh module instance with the env var +// set. The credential manager stub is created inside the isolation callback. +// =========================================================================== + +describe('LicenseManager: activate() — online validation', () => { + test('uses online server when LICENSE_SERVER_URL is set and returns success', async () => { + const originalFetch = global.fetch; + const dir = _tmpDir(); + const prevUrl = process.env.LICENSE_SERVER_URL; + process.env.LICENSE_SERVER_URL = 'https://license.test.example'; + try { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + codeId: 999, + durationDays: 365, + expiresAt: new Date(Date.now() + 365 * 86400000).toISOString(), + features: ['sso', 'recipes', 'swarm'], + token: 'srv-token-abc', + }), + }); + + const creds = { + _store: {}, + async store(key, val) { this._store[key] = val; }, + async retrieve(key) { return this._store[key] || null; }, + async delete(key) { delete this._store[key]; }, + }; + + let result; + jest.isolateModules(() => { + const { LicenseManager } = require('../src/managers/license-manager'); + const mgr = new LicenseManager(creds, path.join(dir, 'config.json'), { info: () => {}, warn: () => {}, error: () => {} }); + // Run synchronously — activate is async but we capture the promise + result = mgr.activate('DC-ABCDE-FGHIJ-KLMNO-PQRST-UVWXY'); + }); + + const res = await result; + expect(res.success).toBe(true); + expect(res.activation.validationMethod).toBe('online'); + expect(global.fetch).toHaveBeenCalledWith( + 'https://license.test.example/api/license/validate', + expect.objectContaining({ method: 'POST' }) + ); + } finally { + if (prevUrl === undefined) delete process.env.LICENSE_SERVER_URL; + else process.env.LICENSE_SERVER_URL = prevUrl; + global.fetch = originalFetch; + _cleanup(dir); + } + }); + + test('falls back to offline when server is unreachable (fetch throws)', async () => { + const originalFetch = global.fetch; + const dir = _tmpDir(); + const prevUrl = process.env.LICENSE_SERVER_URL; + process.env.LICENSE_SERVER_URL = 'https://license.test.example'; + try { + global.fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); + + const secretFile = path.join(dir, '.license-secret'); + fs.writeFileSync(secretFile, TEST_SECRET, 'utf8'); + + const creds = { + _store: {}, + async store(key, val) { this._store[key] = val; }, + async retrieve(key) { return this._store[key] || null; }, + async delete(key) { delete this._store[key]; }, + }; + + let result; + jest.isolateModules(() => { + const { LicenseManager } = require('../src/managers/license-manager'); + const mgr = new LicenseManager(creds, path.join(dir, 'config.json'), { info: () => {}, warn: () => {}, error: () => {} }); + mgr.loadSecret(secretFile); + const code = _mintCode(TEST_SECRET, 30, 1200); + result = mgr.activate(code); + }); + + const res = await result; + expect(res.success).toBe(true); + expect(res.activation.validationMethod).toBe('offline'); + } finally { + if (prevUrl === undefined) delete process.env.LICENSE_SERVER_URL; + else process.env.LICENSE_SERVER_URL = prevUrl; + global.fetch = originalFetch; + _cleanup(dir); + } + }); + + test('rejects when server explicitly returns failure (no fallback)', async () => { + const originalFetch = global.fetch; + const dir = _tmpDir(); + const prevUrl = process.env.LICENSE_SERVER_URL; + process.env.LICENSE_SERVER_URL = 'https://license.test.example'; + try { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: 'Code revoked by administrator' }), + }); + + const creds = { + _store: {}, + async store(key, val) { this._store[key] = val; }, + async retrieve(key) { return this._store[key] || null; }, + async delete(key) { delete this._store[key]; }, + }; + + let result; + jest.isolateModules(() => { + const { LicenseManager } = require('../src/managers/license-manager'); + const mgr = new LicenseManager(creds, path.join(dir, 'config.json'), { info: () => {}, warn: () => {}, error: () => {} }); + // Use a code with correct length (25 base32 chars = 5 groups of 5) + result = mgr.activate('DC-ABCDE-FGHIJ-KLMNO-PQRST-UVWXY'); + }); + + const res = await result; + expect(res.success).toBe(false); + expect(res.message).toMatch(/revoked/i); + } finally { + if (prevUrl === undefined) delete process.env.LICENSE_SERVER_URL; + else process.env.LICENSE_SERVER_URL = prevUrl; + global.fetch = originalFetch; + _cleanup(dir); + } + }); +}); + +// =========================================================================== +// deactivate() +// =========================================================================== + +describe('LicenseManager: deactivate()', () => { + test('deactivates an active license', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 1300); + await mgr.activate(code); + expect(mgr.isPro()).toBe(true); + + const result = await mgr.deactivate(); + expect(result.success).toBe(true); + expect(result.message).toMatch(/deactivated/i); + expect(mgr.activation).toBeNull(); + expect(mgr.isPro()).toBe(false); + } finally { await restore(); } + }); + + test('clears credential store on deactivation', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 1400); + await mgr.activate(code); + expect(await mgr.credentialManager.retrieve('license.activation')).toBeTruthy(); + + await mgr.deactivate(); + expect(await mgr.credentialManager.retrieve('license.activation')).toBeNull(); + } finally { await restore(); } + }); + + test('updates config.json to free tier after deactivation', async () => { + const { mgr, restore, configFile } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 1500); + await mgr.activate(code); + + // Verify config has license + let config = JSON.parse(fs.readFileSync(configFile, 'utf8')); + expect(config.license.active).toBe(true); + + await mgr.deactivate(); + + config = JSON.parse(fs.readFileSync(configFile, 'utf8')); + expect(config.license.active).toBe(false); + expect(config.license.tier).toBe('free'); + expect(config.licenseBackup).toBeUndefined(); + } finally { await restore(); } + }); + + test('returns failure when no active license', async () => { + const { mgr, restore } = _makeManager(); + try { + const result = await mgr.deactivate(); + expect(result.success).toBe(false); + expect(result.message).toMatch(/no active/i); + } finally { await restore(); } + }); +}); + +// =========================================================================== +// getStatus() +// =========================================================================== + +describe('LicenseManager: getStatus()', () => { + test('returns free tier when no activation', () => { + const { mgr, restore } = _makeManager(); + try { + const status = mgr.getStatus(); + expect(status.active).toBe(false); + expect(status.tier).toBe('free'); + expect(status.features).toEqual([]); + expect(status.premiumFeatures).toBeTruthy(); + } finally { restore(); } + }); + + test('returns premium tier for active license', () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + mgr.activation = { + code: 'DC-STATUS-TEST', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + const status = mgr.getStatus(); + expect(status.active).toBe(true); + expect(status.tier).toBe('premium'); + expect(status.features).toContain('sso'); + expect(status.daysRemaining).toBeGreaterThan(29); + } finally { restore(); } + }); + + test('returns free tier for expired license', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-EXPIRED-STATUS', + durationDays: 30, + lifetime: false, + activatedAt: '2020-01-01T00:00:00Z', + expiresAt: '2020-02-01T00:00:00Z', + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + const status = mgr.getStatus(); + expect(status.active).toBe(false); + expect(status.tier).toBe('free'); + expect(status.expired).toBe(true); + expect(status.features).toEqual([]); + } finally { restore(); } + }); + + test('returns null expiresAt/daysRemaining for lifetime', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-LIFETIME-STATUS', + durationDays: 0, + lifetime: true, + activatedAt: new Date().toISOString(), + expiresAt: new Date('2099-12-31T23:59:59.999Z').toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso', 'recipes'], + }; + const status = mgr.getStatus(); + expect(status.active).toBe(true); + expect(status.lifetime).toBe(true); + expect(status.expiresAt).toBeNull(); + expect(status.daysRemaining).toBeNull(); + } finally { restore(); } + }); + + test('masks the code in status output', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-ABCDE-FGHIJ-KLMNO-PQRST-UVWXY', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + const status = mgr.getStatus(); + expect(status.code).not.toBe(mgr.activation.code); + expect(status.code).toMatch(/^DC-/); + expect(status.code).toContain('*****'); + } finally { restore(); } + }); +}); + +// =========================================================================== +// hasFeature() +// =========================================================================== + +describe('LicenseManager: hasFeature()', () => { + test('returns false when no activation', () => { + const { mgr, restore } = _makeManager(); + try { + expect(mgr.hasFeature('sso')).toBe(false); + } finally { restore(); } + }); + + test('returns true for available feature on active license', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-FEATURE-TEST', + durationDays: 90, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso', 'recipes'], + }; + expect(mgr.hasFeature('sso')).toBe(true); + expect(mgr.hasFeature('recipes')).toBe(true); + } finally { restore(); } + }); + + test('returns false for unavailable feature', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-FEATURE-TEST', + durationDays: 90, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + expect(mgr.hasFeature('swarm')).toBe(false); + } finally { restore(); } + }); + + test('returns false when license expired', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-EXPIRED-FEATURE', + durationDays: 30, + lifetime: false, + activatedAt: '2020-01-01T00:00:00Z', + expiresAt: '2020-02-01T00:00:00Z', + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + expect(mgr.hasFeature('sso')).toBe(false); + } finally { restore(); } + }); + + test('falls back to PREMIUM_FEATURES keys when activation.features missing', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-NO-FEATURES-LIST', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + // features omitted + }; + // Should default to all PREMIUM_FEATURES + expect(mgr.hasFeature('sso')).toBe(true); + expect(mgr.hasFeature('recipes')).toBe(true); + expect(mgr.hasFeature('swarm')).toBe(true); + } finally { restore(); } + }); +}); + +// =========================================================================== +// isPro() / isExpired() / daysRemaining() +// =========================================================================== + +describe('LicenseManager: isPro()', () => { + test('false when no activation', () => { + const { mgr, restore } = _makeManager(); + try { + expect(mgr.isPro()).toBe(false); + } finally { restore(); } + }); + + test('true for active non-lifetime license', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-PRO-TEST', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + expect(mgr.isPro()).toBe(true); + } finally { restore(); } + }); + + test('true for active lifetime license', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-LIFETIME-PRO', + durationDays: 0, + lifetime: true, + activatedAt: new Date().toISOString(), + expiresAt: new Date('2099-12-31').toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + expect(mgr.isPro()).toBe(true); + } finally { restore(); } + }); + + test('false for expired license', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-EXPIRED-PRO', + durationDays: 30, + lifetime: false, + activatedAt: '2020-01-01T00:00:00Z', + expiresAt: '2020-02-01T00:00:00Z', + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + expect(mgr.isPro()).toBe(false); + } finally { restore(); } + }); +}); + +describe('LicenseManager: isExpired()', () => { + test('true when no activation', () => { + const { mgr, restore } = _makeManager(); + try { + expect(mgr.isExpired()).toBe(true); + } finally { restore(); } + }); + + test('false for lifetime (durationDays=0)', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { durationDays: 0, lifetime: true, expiresAt: null }; + expect(mgr.isExpired()).toBe(false); + } finally { restore(); } + }); + + test('false for lifetime flag only (no durationDays)', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { lifetime: true, expiresAt: '2020-01-01T00:00:00Z' }; + expect(mgr.isExpired()).toBe(false); + } finally { restore(); } + }); + + test('false when expiresAt is null/missing (treated as lifetime)', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { durationDays: 30, lifetime: false, expiresAt: null }; + expect(mgr.isExpired()).toBe(false); + } finally { restore(); } + }); + + test('true when expiry is in the past', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + durationDays: 30, + lifetime: false, + expiresAt: new Date(Date.now() - 86400000).toISOString(), + }; + expect(mgr.isExpired()).toBe(true); + } finally { restore(); } + }); + + test('false when expiry is in the future', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + durationDays: 30, + lifetime: false, + expiresAt: new Date(Date.now() + 86400000).toISOString(), + }; + expect(mgr.isExpired()).toBe(false); + } finally { restore(); } + }); +}); + +describe('LicenseManager: daysRemaining()', () => { + test('returns 0 when no activation', () => { + const { mgr, restore } = _makeManager(); + try { + expect(mgr.daysRemaining()).toBe(0); + } finally { restore(); } + }); + + test('returns positive days for active license', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + expiresAt: new Date(Date.now() + 15 * 86400000).toISOString(), + }; + const days = mgr.daysRemaining(); + expect(days).toBeGreaterThanOrEqual(14); + expect(days).toBeLessThanOrEqual(15); + } finally { restore(); } + }); + + test('returns negative for expired license (Math.ceil of negative)', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + expiresAt: new Date(Date.now() - 5 * 86400000).toISOString(), + }; + const days = mgr.daysRemaining(); + expect(days).toBeLessThanOrEqual(-4); + } finally { restore(); } + }); +}); + +// =========================================================================== +// getMachineFingerprint() +// =========================================================================== + +describe('LicenseManager: getMachineFingerprint()', () => { + test('returns a 16-char hex string', () => { + const { mgr, restore } = _makeManager(); + try { + const fp = mgr.getMachineFingerprint(); + expect(fp).toMatch(/^[0-9a-f]{16}$/); + } finally { restore(); } + }); + + test('is stable across calls (same machine)', () => { + const { mgr, restore } = _makeManager(); + try { + const fp1 = mgr.getMachineFingerprint(); + const fp2 = mgr.getMachineFingerprint(); + expect(fp1).toBe(fp2); + } finally { restore(); } + }); +}); + +// =========================================================================== +// requirePremium() middleware +// =========================================================================== + +describe('LicenseManager: requirePremium() middleware', () => { + test('calls next() when feature is available', () => { + const { mgr, restore } = _makeManager(); + try { + mgr.activation = { + code: 'DC-MW-TEST', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + + const middleware = mgr.requirePremium('sso'); + const req = {}; + const res = {}; + let nextCalled = false; + middleware(req, res, (err) => { nextCalled = !err; }); + expect(nextCalled).toBe(true); + } finally { restore(); } + }); + + test('returns 403 with premiumRequired when feature unavailable', () => { + const { mgr, restore } = _makeManager(); + try { + const middleware = mgr.requirePremium('sso'); + const req = {}; + let status = null; + let body = null; + const res = { + status(s) { status = s; return this; }, + json(b) { body = b; return this; }, + }; + middleware(req, res, () => {}); + expect(status).toBe(403); + expect(body.success).toBe(false); + expect(body.premiumRequired).toBe(true); + expect(body.feature).toBe('sso'); + } finally { restore(); } + }); + + test('includes upgrade URL in 403 response', () => { + const { mgr, restore } = _makeManager(); + try { + const middleware = mgr.requirePremium('recipes'); + const req = {}; + let body = null; + const res = { + status() { return this; }, + json(b) { body = b; return this; }, + }; + middleware(req, res, () => {}); + expect(body.upgradeUrl).toMatch(/settings.*license|license.*settings/i); + } finally { restore(); } + }); + + test('handles unknown feature key gracefully', () => { + const { mgr, restore } = _makeManager(); + try { + const middleware = mgr.requirePremium('nonexistent'); + const req = {}; + let body = null; + const res = { + status() { return this; }, + json(b) { body = b; return this; }, + }; + middleware(req, res, () => {}); + expect(body.featureName).toBe('nonexistent'); + } finally { restore(); } + }); +}); + +// =========================================================================== +// loadSecret() +// =========================================================================== + +describe('LicenseManager: loadSecret()', () => { + test('loads secret from file and returns true', () => { + const dir = _tmpDir(); + try { + const secretFile = path.join(dir, '.license-secret'); + fs.writeFileSync(secretFile, 'testsecret123', 'utf8'); + + delete require.cache[require.resolve('../src/managers/license-manager')]; + const { LicenseManager } = require('../src/managers/license-manager'); + const mgr = new LicenseManager({}, '/nonexistent', { info: () => {} }); + + const result = mgr.loadSecret(secretFile); + expect(result).toBe(true); + expect(mgr.masterSecretHash).toBe('testsecret123'); + } finally { _cleanup(dir); } + }); + + test('returns false when secret file does not exist', () => { + const { mgr, restore } = _makeManager(); + try { + const result = mgr.loadSecret('/nonexistent/secret/path'); + expect(result).toBe(false); + expect(mgr.masterSecretHash).toBeNull(); + } finally { restore(); } + }); + + test('returns false and logs warning when secret file read throws', () => { + const dir = _tmpDir(); + // Mock fs.existsSync + fs.readFileSync; restore in finally so any + // failure doesn't poison the rest of the suite. + const origExists = fs.existsSync; + const origRead = fs.readFileSync; + let warnCalled = false; + try { + fs.existsSync = () => true; + fs.readFileSync = () => { throw new Error('EACCES: permission denied'); }; + + delete require.cache[require.resolve('../src/managers/license-manager')]; + const { LicenseManager } = require('../src/managers/license-manager'); + const mgr = new LicenseManager({}, '/nonexistent', { info: () => {}, warn: () => { warnCalled = true; } }); + + const result = mgr.loadSecret('/fake/secret/path'); + expect(result).toBe(false); + expect(mgr.masterSecretHash).toBeNull(); + expect(warnCalled).toBe(true); + } finally { + fs.existsSync = origExists; + fs.readFileSync = origRead; + _cleanup(dir); + } + }); +}); + +// =========================================================================== +// _validateOffline() +// =========================================================================== + +describe('LicenseManager: _validateOffline()', () => { + test('validates real code with loaded secret', () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const code = _mintCode(TEST_SECRET, 30, 1600); + const result = mgr._validateOffline(code); + expect(result.valid).toBe(true); + expect(result.durationDays).toBe(30); + expect(result.codeId).toBe(1600); + } finally { restore(); } + }); + + test('rejects forged code (HMAC mismatch)', () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const wrongSecret = crypto.randomBytes(32).toString('hex'); + const forgedCode = _mintCode(wrongSecret, 30, 1700); + const result = mgr._validateOffline(forgedCode); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/signature|forged|corrupted/i); + } finally { restore(); } + }); + + test('returns validation-unavailable when no secret loaded', () => { + const { mgr, restore } = _makeManager({ secret: null }); + try { + const code = _mintCode(TEST_SECRET, 30, 1800); + const result = mgr._validateOffline(code); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/unavailable|internet/i); + } finally { restore(); } + }); + + test('returns invalid for malformed code string', () => { + const { mgr, restore } = _makeManager({ secret: null }); + try { + const result = mgr._validateOffline('DC-GARBAGE'); + expect(result.valid).toBe(false); + } finally { restore(); } + }); + + test('rejects code with unsupported version (forged version-2 payload)', () => { + // Construct a code whose version nibble is 2 (not the current VERSION=1). + // We can't use generateCode() because it hardcodes VERSION=1, so we + // manually pack a version=2 payload, sign it with the correct HMAC, and + // base32-encode it into the DC-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX format. + const { generateCode, parseCode } = require('../license-keygen'); + // Generate a valid version-1 code, decode it, bump the version nibble, + // re-sign, re-encode. This gives a cryptographically well-formed code + // with version=2 — the structural check should catch it. + const realCode = generateCode(TEST_SECRET, 30, 1900); + // Decode payload + signature from the real code + const cleaned = realCode.replace(/^DC-/, '').replace(/-/g, ''); + const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + function b32Decode(str) { + let bits = ''; + for (const ch of str.toUpperCase()) { bits += BASE32.indexOf(ch).toString(2).padStart(5, '0'); } + const bytes = []; + for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.substring(i, i + 8), 2)); + return Buffer.from(bytes); + } + const decoded = b32Decode(cleaned); + const payload = Buffer.from(decoded.subarray(0, 10)); + // Overwrite version nibble: bits 12-15 of the first 16-bit value. + const versionAndDuration = payload.readUInt16BE(0); + const duration = versionAndDuration & 0x0FFF; + payload.writeUInt16BE((2 << 12) | duration, 0); // version=2 + // Re-sign with the same secret so HMAC is valid for the tampered payload + const hmac = crypto.createHmac('sha256', TEST_SECRET).update(payload).digest(); + const signature = hmac.subarray(0, 5); + const combined = Buffer.concat([payload, signature]); + // Re-encode base32 + function b32Encode(buf) { + let bits = ''; + for (const b of buf) bits += b.toString(2).padStart(8, '0'); + while (bits.length % 5 !== 0) bits += '0'; + let result = ''; + for (let i = 0; i < bits.length; i += 5) result += BASE32[parseInt(bits.substring(i, i + 5), 2)]; + return result; + } + let encoded = b32Encode(combined); + while (encoded.length < 25) encoded += '0'; + encoded = encoded.substring(0, 25); + const groups = []; + for (let i = 0; i < 25; i += 5) groups.push(encoded.substring(i, i + 5)); + const forgedV2Code = `DC-${groups.join('-')}`; + + // Verify the forged code actually parses as version=2 + const parsed = parseCode(forgedV2Code); + expect(parsed.version).toBe(2); + + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + const result = mgr._validateOffline(forgedV2Code); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/version/i); + } finally { restore(); } + }); +}); + +// =========================================================================== +// _maskCode() +// =========================================================================== + +describe('LicenseManager: _maskCode()', () => { + test('masks a standard code (DC + 5 groups)', () => { + const { mgr, restore } = _makeManager(); + try { + const masked = mgr._maskCode('DC-ABCDE-FGHIJ-KLMNO-PQRST-UVWXY'); + expect(masked).toBe('DC-ABCDE-*****-*****-UVWXY'); + } finally { restore(); } + }); + + test('returns DC-***** for short code (< 4 groups)', () => { + const { mgr, restore } = _makeManager(); + try { + expect(mgr._maskCode('DC-ABC')).toBe('DC-*****'); + expect(mgr._maskCode('DC-ABC-DEF')).toBe('DC-*****'); + } finally { restore(); } + }); + + test('returns "none" for null/empty', () => { + const { mgr, restore } = _makeManager(); + try { + expect(mgr._maskCode(null)).toBe('none'); + expect(mgr._maskCode('')).toBe('none'); + expect(mgr._maskCode(undefined)).toBe('none'); + } finally { restore(); } + }); +}); + +// =========================================================================== +// allowsLifetimeLicense() +// =========================================================================== + +describe('LicenseManager: allowsLifetimeLicense()', () => { + test('defaults to false', () => { + const { mgr, restore } = _makeManager(); + try { + expect(mgr.allowsLifetimeLicense()).toBe(false); + } finally { restore(); } + }); + + test('returns true when ALLOW_LIFETIME_LICENSE=true', () => { + const { mgr, restore } = _makeManager({ + env: { ALLOW_LIFETIME_LICENSE: 'true' }, + }); + try { + expect(mgr.allowsLifetimeLicense()).toBe(true); + } finally { restore(); } + }); + + test('returns false for non-"true" values', () => { + const { mgr, restore } = _makeManager({ + env: { ALLOW_LIFETIME_LICENSE: 'false' }, + }); + try { + expect(mgr.allowsLifetimeLicense()).toBe(false); + } finally { restore(); } + }); +}); + +// =========================================================================== +// _updateConfig() — internal but critical for persistence +// =========================================================================== + +describe('LicenseManager: _updateConfig()', () => { + test('creates config.json if it does not exist', async () => { + const { mgr, restore, configFile } = _makeManager(); + try { + expect(fs.existsSync(configFile)).toBe(false); + + mgr.activation = { + code: 'DC-CREATE-CONFIG', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + + await mgr._updateConfig(); + expect(fs.existsSync(configFile)).toBe(true); + const config = JSON.parse(fs.readFileSync(configFile, 'utf8')); + expect(config.license.active).toBe(true); + expect(config.licenseBackup.code).toBe('DC-CREATE-CONFIG'); + } finally { restore(); } + }); + + test('preserves existing config fields when updating', async () => { + const { mgr, restore, configFile } = _makeManager(); + try { + fs.writeFileSync(configFile, JSON.stringify({ + tld: '.sami', + existingField: 'preserved', + }, null, 2)); + + mgr.activation = { + code: 'DC-PRESERVE-CONFIG', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + validationMethod: 'offline', + features: ['sso'], + }; + + await mgr._updateConfig(); + const config = JSON.parse(fs.readFileSync(configFile, 'utf8')); + expect(config.tld).toBe('.sami'); + expect(config.existingField).toBe('preserved'); + expect(config.license.active).toBe(true); + } finally { restore(); } + }); + + test('clears licenseBackup when activation is null/expired', async () => { + const { mgr, restore, configFile } = _makeManager(); + try { + fs.writeFileSync(configFile, JSON.stringify({ + license: { active: true, tier: 'premium' }, + licenseBackup: { code: 'DC-OLD', durationDays: 30 }, + }, null, 2)); + + mgr.activation = null; + await mgr._updateConfig(); + const config = JSON.parse(fs.readFileSync(configFile, 'utf8')); + expect(config.license.active).toBe(false); + expect(config.license.tier).toBe('free'); + expect(config.licenseBackup).toBeUndefined(); + } finally { restore(); } + }); + + test('does not crash when config file directory does not exist', async () => { + const { mgr, restore } = _makeManager(); + try { + // Point to a path inside a nonexistent directory + mgr.configFile = '/nonexistent/dir/config.json'; + mgr.activation = { + code: 'DC-NO-CRASH', + durationDays: 30, + lifetime: false, + activatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + machineId: 'test', + features: ['sso'], + }; + // Should not throw — _updateConfig catches internally + await expect(mgr._updateConfig()).resolves.not.toThrow(); + } finally { restore(); } + }); +}); + +// =========================================================================== +// Full lifecycle integration +// =========================================================================== + +describe('LicenseManager: full lifecycle integration', () => { + test('activate → getStatus → deactivate → getStatus', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + // Initially free + expect(mgr.getStatus().tier).toBe('free'); + + // Activate + const code = _mintCode(TEST_SECRET, 90, 2000); + const activateResult = await mgr.activate(code); + expect(activateResult.success).toBe(true); + + // Check status is premium + const activeStatus = mgr.getStatus(); + expect(activeStatus.active).toBe(true); + expect(activeStatus.tier).toBe('premium'); + expect(activeStatus.durationDays).toBe(90); + + // Deactivate + const deactivateResult = await mgr.deactivate(); + expect(deactivateResult.success).toBe(true); + + // Back to free + expect(mgr.getStatus().tier).toBe('free'); + expect(mgr.isPro()).toBe(false); + } finally { await restore(); } + }); + + test('load() after activate() restores the same activation', async () => { + const creds = { + _store: {}, + async store(key, val) { this._store[key] = val; }, + async retrieve(key) { return this._store[key] || null; }, + async delete(key) { delete this._store[key]; }, + }; + const dir = _tmpDir(); + try { + const configFile = path.join(dir, 'config.json'); + const secretFile = path.join(dir, '.license-secret'); + fs.writeFileSync(secretFile, TEST_SECRET, 'utf8'); + + delete require.cache[require.resolve('../src/managers/license-manager')]; + const { LicenseManager } = require('../src/managers/license-manager'); + + // Activate + const mgr1 = new LicenseManager(creds, configFile, { info: () => {} }); + mgr1.loadSecret(secretFile); + const code = _mintCode(TEST_SECRET, 30, 2100); + await mgr1.activate(code); + const originalCodeId = mgr1.activation.codeId; + + // Simulate restart: create a NEW manager with same creds + config + const mgr2 = new LicenseManager(creds, configFile, { info: () => {} }); + await mgr2.load(); + + expect(mgr2.activation).toBeTruthy(); + expect(mgr2.activation.code).toBe(code); + expect(mgr2.activation.codeId).toBe(originalCodeId); + expect(mgr2.isPro()).toBe(true); + } finally { _cleanup(dir); } + }); + + test('freshly minted code validates as active (not expired)', async () => { + const { mgr, restore } = _makeManager({ secret: TEST_SECRET }); + try { + // Generate a code and verify it independently + const code = _mintCode(TEST_SECRET, 30, 2200); + const verifyResult = verifyCode(TEST_SECRET, code); + expect(verifyResult.valid).toBe(true); + expect(verifyResult.durationDays).toBe(30); + expect(verifyResult.expired).toBe(false); + + // Activate should succeed (code is cryptographically valid + not expired) + const result = await mgr.activate(code); + expect(result.success).toBe(true); + expect(result.activation.expired).toBe(false); + } finally { await restore(); } + }); +});