// Tests for server/license.js — the offline Ed25519 verifier wrapper. // // license.js reads its paths (LICENSE_PATH, STARTOS_CONFIG_PATH) at module // load time from env vars. To isolate test fixtures, we set RECAP_LICENSE_ // KEY_PATH + DATA_DIR to a tmp dir BEFORE importing the module — which is // why this file uses dynamic import(). import { test, describe, before, after } from "node:test"; import { strict as assert } from "node:assert"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; let tmpDir; let licensePath; let licenseModule; before(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "recap-license-test-")); licensePath = path.join(tmpDir, "license.txt"); process.env.DATA_DIR = tmpDir; process.env.RECAP_LICENSE_KEY_PATH = licensePath; // Make sure config dir exists so the fallback read in readLicenseString // doesn't fail on a missing parent. await fs.mkdir(path.join(tmpDir, "config"), { recursive: true }); // Dynamic import after env vars are in place. licenseModule = await import("../license.js"); }); after(async () => { delete process.env.RECAP_LICENSE_KEY_PATH; delete process.env.DATA_DIR; await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); }); describe("checkLicense", () => { test("returns 'unlicensed' when no key file exists", async () => { await fs.unlink(licensePath).catch(() => {}); const state = licenseModule.checkLicense(); assert.equal(state.state, "unlicensed"); assert.equal(state.reason, null); assert.deepEqual([...state.entitlements], []); }); test("returns 'invalid' for a malformed key", async () => { await fs.writeFile(licensePath, "garbage-not-a-license"); const state = licenseModule.checkLicense(); assert.equal(state.state, "invalid"); assert.ok(state.reason, "should have a reason"); }); test("returns 'invalid' for a LIC1 prefix without valid base32 body", async () => { await fs.writeFile(licensePath, "LIC1-not-real-key"); const state = licenseModule.checkLicense(); assert.equal(state.state, "invalid"); }); test("falls back to startos-config.json when license.txt is missing", async () => { await fs.unlink(licensePath).catch(() => {}); const cfgPath = path.join(tmpDir, "config", "startos-config.json"); await fs.writeFile(cfgPath, JSON.stringify({ recap_license_key: "LIC1-bad-but-detected" })); const state = licenseModule.checkLicense(); // Will be 'invalid' (bad key) but NOT 'unlicensed' — proves the fallback ran. assert.equal(state.state, "invalid"); await fs.unlink(cfgPath).catch(() => {}); }); test("license.txt takes priority over startos-config.json", async () => { await fs.writeFile(licensePath, "LIC1-from-license-txt"); const cfgPath = path.join(tmpDir, "config", "startos-config.json"); await fs.writeFile(cfgPath, JSON.stringify({ recap_license_key: "LIC1-from-config" })); const state = licenseModule.checkLicense(); // Both are bad, but the reason should reflect license.txt's content, // not the config's. We can't easily distinguish without a real key, // but we can confirm it returns 'invalid' (no precedence-related crash). assert.equal(state.state, "invalid"); await fs.unlink(cfgPath).catch(() => {}); await fs.unlink(licensePath).catch(() => {}); }); }); describe("activate / deactivate", () => { test("activate rejects keys missing the LIC1- prefix", () => { assert.throws(() => licenseModule.activate("not-a-license"), { code: "bad_format" }); assert.throws(() => licenseModule.activate(""), { code: "bad_format" }); assert.throws(() => licenseModule.activate(null), { code: "bad_format" }); }); test("activate writes a LIC1-prefixed string to license.txt", async () => { const fake = "LIC1-fake-but-shaped-correctly"; const state = licenseModule.activate(fake); const written = (await fs.readFile(licensePath, "utf-8")).trim(); assert.equal(written, fake); // Returned state will be 'invalid' (signature fails) — not our concern here. assert.ok(["invalid", "licensed"].includes(state.state)); }); test("deactivate removes license.txt and the state sidecar", async () => { await fs.writeFile(licensePath, "LIC1-anything"); licenseModule.deactivate(); await assert.rejects(fs.access(licensePath), /ENOENT/); const state = licenseModule.checkLicense(); assert.equal(state.state, "unlicensed"); }); test("deactivate is idempotent on a clean slate", () => { // No file → should not throw licenseModule.deactivate(); licenseModule.deactivate(); }); }); describe("publicView", () => { test("never includes the raw license key", () => { const state = licenseModule.checkLicense(); const view = licenseModule.publicView(state); // The public view should not contain anything that looks like a license string const flat = JSON.stringify(view); assert.ok(!flat.includes("LIC1-")); }); test("converts entitlements Set to a sorted array", () => { const view = licenseModule.publicView({ state: "licensed", reason: null, licenseId: "abc", entitlements: new Set(["library", "core", "history"]), expiresAt: null, isTrial: false, }); assert.deepEqual(view.entitlements, ["core", "history", "library"]); }); test("ISO-formats expiresAt and graceUntil", () => { const expiresAt = new Date("2027-01-01T00:00:00.000Z"); const view = licenseModule.publicView({ state: "licensed", reason: null, licenseId: null, entitlements: new Set(), expiresAt, isTrial: false, }); assert.equal(view.expiresAt, "2027-01-01T00:00:00.000Z"); }); test("always exposes productSlug = 'recap'", () => { const view = licenseModule.publicView({ state: "unlicensed", reason: null, licenseId: null, entitlements: new Set(), expiresAt: null, isTrial: false, }); assert.equal(view.productSlug, "recap"); }); }); describe("has(state, entitlement)", () => { test("returns true when entitlement present", () => { assert.equal( licenseModule.has({ entitlements: new Set(["core"]) }, "core"), true ); }); test("returns false when entitlement absent", () => { assert.equal( licenseModule.has({ entitlements: new Set(["core"]) }, "subscriptions"), false ); }); test("returns falsy on null state", () => { assert.ok(!licenseModule.has(null, "core")); assert.ok(!licenseModule.has(undefined, "core")); assert.ok(!licenseModule.has({}, "core")); }); });