Files
CloakBrowser/js/tests/license.test.ts
T

313 lines
9.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import {
resolveLicenseKey,
validateLicense,
getProLatestVersion,
} from "../src/license.js";
import * as config from "../src/config.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = path.join("/tmp", `cloakbrowser-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
vi.spyOn(config, "getCacheDir").mockReturnValue(tmpDir);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {}
});
// ── resolveLicenseKey ─────────────────────────────────
describe("resolveLicenseKey", () => {
it("explicit param wins over env", () => {
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
expect(resolveLicenseKey("explicit")).toBe("explicit");
delete process.env.CLOAKBROWSER_LICENSE_KEY;
});
it("env var fallback", () => {
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
expect(resolveLicenseKey()).toBe("env-key");
delete process.env.CLOAKBROWSER_LICENSE_KEY;
});
it("returns undefined when absent", () => {
delete process.env.CLOAKBROWSER_LICENSE_KEY;
expect(resolveLicenseKey()).toBeUndefined();
});
it("file fallback when no param or env", () => {
delete process.env.CLOAKBROWSER_LICENSE_KEY;
const keyFile = path.join(tmpDir, "license.key");
fs.writeFileSync(keyFile, "file-key-123\n");
expect(resolveLicenseKey()).toBe("file-key-123");
});
it("env takes precedence over file", () => {
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
const keyFile = path.join(tmpDir, "license.key");
fs.writeFileSync(keyFile, "file-key");
expect(resolveLicenseKey()).toBe("env-key");
delete process.env.CLOAKBROWSER_LICENSE_KEY;
});
it("returns undefined when file missing", () => {
delete process.env.CLOAKBROWSER_LICENSE_KEY;
expect(resolveLicenseKey()).toBeUndefined();
});
});
// ── validateLicense ───────────────────────────────────
describe("validateLicense", () => {
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
it("fresh cache skips server call", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "team",
expires: "2026-12-01",
validated_at: Date.now() / 1000,
})
);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const result = await validateLicense("test-key");
expect(fetchSpy).not.toHaveBeenCalled();
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
expect(result!.plan).toBe("team");
});
it("stale cache triggers server call", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "solo",
expires: null,
validated_at: Date.now() / 1000 - 90000, // 25 hours ago
})
);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
const result = await validateLicense("test-key");
expect(globalThis.fetch).toHaveBeenCalledOnce();
expect(result!.valid).toBe(true);
});
it("server success returns LicenseInfo", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "business", expires: "2026-07-13" }),
} as Response);
const result = await validateLicense("pro-key");
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
expect(result!.plan).toBe("business");
expect(result!.expires).toBe("2026-07-13");
});
it("server rejection returns invalid", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: false, plan: "solo", expires: null }),
} as Response);
const result = await validateLicense("bad-key");
expect(result).not.toBeNull();
expect(result!.valid).toBe(false);
});
it("server unreachable uses stale cache", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "solo",
expires: "2026-12-01",
validated_at: Date.now() / 1000 - 90000,
})
);
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const result = await validateLicense("test-key");
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
});
it("server unreachable no cache returns null", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const result = await validateLicense("test-key");
expect(result).toBeNull();
});
it("cache stores hash not raw key", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
await validateLicense("secret-key-123");
const cachePath = path.join(tmpDir, ".license_cache");
const content = fs.readFileSync(cachePath, "utf-8");
expect(content).not.toContain("secret-key-123");
const expectedSha = crypto
.createHash("sha256")
.update("secret-key-123")
.digest("hex");
expect(content).toContain(expectedSha);
});
it("wrong key cache ignored", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: "other-hash",
valid: true,
plan: "solo",
expires: null,
validated_at: Date.now() / 1000,
})
);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
await validateLicense("different-key");
expect(globalThis.fetch).toHaveBeenCalledOnce();
});
it("expired license rejected from cache", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "solo",
expires: "2020-01-01T00:00:00+00:00",
validated_at: Date.now() / 1000,
})
);
const result = await validateLicense("test-key");
expect(result).not.toBeNull();
expect(result!.valid).toBe(false);
});
it("does not cache invalid responses", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: false, plan: "solo", expires: null }),
} as Response);
await validateLicense("bad-key");
const cachePath = path.join(tmpDir, ".license_cache");
expect(fs.existsSync(cachePath)).toBe(false);
});
it("corrupted validated_at is treated as absent cache, not trusted", async () => {
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
fs.writeFileSync(
path.join(tmpDir, ".license_cache"),
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "solo",
expires: null,
validated_at: "not-a-number",
})
);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
const result = await validateLicense("test-key");
expect(globalThis.fetch).toHaveBeenCalledOnce(); // corrupted cache ignored → server hit
expect(result!.valid).toBe(true);
});
});
// ── getProLatestVersion ───────────────────────────────
describe("getProLatestVersion", () => {
it("fetches version from server", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "147.0.1234.5" }),
} as Response);
const version = await getProLatestVersion();
expect(version).toBe("147.0.1234.5");
});
it("rate limited by marker file", async () => {
const marker = path.join(tmpDir, ".last_pro_version_check");
fs.writeFileSync(marker, "147.0.1234.5");
const fetchSpy = vi.spyOn(globalThis, "fetch");
const version = await getProLatestVersion();
expect(fetchSpy).not.toHaveBeenCalled();
expect(version).toBe("147.0.1234.5");
});
it("network error returns null", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
const version = await getProLatestVersion();
expect(version).toBeNull();
});
});
// ── Config pro parameter ──────────────────────────────
describe("config pro parameter", () => {
it("getBinaryDir adds -pro suffix", () => {
const normal = config.getBinaryDir("147.0.0.0");
const pro = config.getBinaryDir("147.0.0.0", true);
expect(normal).toMatch(/chromium-147\.0\.0\.0$/);
expect(pro).toMatch(/chromium-147\.0\.0\.0-pro$/);
});
it("getBinaryDir default has no suffix", () => {
const normal = config.getBinaryDir("147.0.0.0");
expect(normal).not.toMatch(/-pro$/);
});
});