Files
recap/server/test/license.test.js
T
Keysat a09ad9c429 Add unit tests for util / gemini-helpers / license / history modules
69 tests across 16 suites, ~120 ms total. Uses node:test (built into
Node 20+) — no new dependency, no Docker rebuild churn. Run with:

  cd server && npm test

Coverage:
  • util.js               extractVideoId, formatTime,
                          parseTimestampedTranscript, safeText,
                          retryGemini (incl. 503 retry, network-error
                          retry, non-retryable passthrough), sendEvent
  • gemini-helpers.js     PRICING table integrity, calcCost (model-
                          specific rates, default fallback, missing
                          fields, sub-cent ¢ formatting, totalTokens
                          precedence), buildAnalysisPrompt
  • license.js            checkLicense (no key, malformed, fallback to
                          startos-config.json, license.txt priority),
                          activate (bad-format throw, file write),
                          deactivate (file removal, idempotent),
                          publicView (no raw key leak, sorted
                          entitlements, ISO dates), has()
  • history.js            initHistory + getHistoryDir, saveToHistory
                          (id shape, defaults, podcast guid encoding),
                          loadMeta + saveMeta round-trip, corrupt-file
                          tolerance

Tests that need module-private file paths (license, history) use a
mkdtemp'd tmp dir as DATA_DIR + dynamic import() so each suite starts
clean. No test mocks the filesystem — they read/write real files
inside the tmp dir, matching production behavior.

Deliberately not yet covered (need an Express app harness or external
binaries): license-middleware (gate behavior), config (live-reload
poll), audio (ffmpeg/ffprobe), ytdlp (yt-dlp + git), cookies (state
mutation routes), the /api/process pipeline. Worth a follow-up after
the current refactor settles.
2026-05-09 10:36:12 -05:00

181 lines
6.5 KiB
JavaScript

// 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"));
});
});