mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: add Pro tier license validation and download routing
This commit is contained in:
+39
-1
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { binaryInfo } from "../src/download.js";
|
||||
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||
import { DEFAULT_VIEWPORT, getBinaryPath, getChromiumVersion, getPlatformTag } from "../src/config.js";
|
||||
import * as config from "../src/config.js";
|
||||
|
||||
describe("binaryInfo", () => {
|
||||
@@ -11,6 +13,7 @@ describe("binaryInfo", () => {
|
||||
const info = binaryInfo();
|
||||
|
||||
expect(info.version).toBe(getChromiumVersion());
|
||||
expect(info.bundledVersion).toBeTruthy();
|
||||
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
|
||||
expect(info.binaryPath).toBeTruthy();
|
||||
expect(typeof info.installed).toBe("boolean");
|
||||
@@ -20,6 +23,41 @@ describe("binaryInfo", () => {
|
||||
else delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports tier from the installed binary, not a cached license", () => {
|
||||
// A valid, fresh license is cached but NO Pro binary is on disk → free.
|
||||
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
const dir = `/tmp/cloakbrowser-test-${Date.now()}-tier`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = dir;
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, ".license_cache"),
|
||||
JSON.stringify({
|
||||
key_sha256: "abc",
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
expect(binaryInfo().tier).toBe("free");
|
||||
|
||||
// Now drop a Pro binary on disk → pro.
|
||||
fs.writeFileSync(path.join(dir, `latest_pro_version_${getPlatformTag()}`), "147.0.5555.1");
|
||||
const bp = getBinaryPath("147.0.5555.1", true);
|
||||
fs.mkdirSync(path.dirname(bp), { recursive: true });
|
||||
fs.writeFileSync(bp, "fake");
|
||||
fs.chmodSync(bp, 0o755);
|
||||
const info = binaryInfo();
|
||||
expect(info.tier).toBe("pro");
|
||||
expect(info.version).toBe("147.0.5555.1");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
|
||||
else delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("composable Playwright launch helpers", () => {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
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$/);
|
||||
});
|
||||
});
|
||||
+103
-1
@@ -26,13 +26,16 @@ vi.mock("../src/config.js", async (importActual) => {
|
||||
});
|
||||
|
||||
import {
|
||||
BinaryVerificationError,
|
||||
downloadProBinary,
|
||||
fetchSignedManifest,
|
||||
parseChecksums,
|
||||
parseManifestVersion,
|
||||
verifyDownloadChecksum,
|
||||
verifyProDownload,
|
||||
verifySignature,
|
||||
} from "../src/download.js";
|
||||
import { getArchiveName, getChromiumVersion } from "../src/config.js";
|
||||
import { DOWNLOAD_BASE_URL, getArchiveName, getChromiumVersion } from "../src/config.js";
|
||||
|
||||
/** Produce SHA256SUMS.sig content (base64 text bytes) for a manifest. */
|
||||
function sign(manifest: Uint8Array): Uint8Array {
|
||||
@@ -173,6 +176,105 @@ describe("verifyDownloadChecksum (official path, fail-closed)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadProBinary (version-pinned URL)", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("requests the explicit version, not /latest", async () => {
|
||||
let capturedUrl = "";
|
||||
// First fetch is the binary download; capture its URL then abort the flow
|
||||
// before verify/extract by returning a non-ok response.
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
capturedUrl = typeof input === "string" ? input : (input as URL).toString();
|
||||
return { ok: false, status: 500, statusText: "stop" } as Response;
|
||||
});
|
||||
|
||||
await downloadProBinary("147.0.1.0", "cb_key").catch(() => {});
|
||||
|
||||
expect(capturedUrl).toBe(`${DOWNLOAD_BASE_URL}/api/download/147.0.1.0`);
|
||||
expect(capturedUrl.endsWith("/latest")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProDownload (Pro path, fail-closed parity)", () => {
|
||||
const PRO_VERSION = "147.0.1.0";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
|
||||
});
|
||||
|
||||
function tmpFile(bytes: Buffer): string {
|
||||
const p = path.join(os.tmpdir(), `cloak-pro-${process.pid}-${bytes.length}-${bytes[0]}`);
|
||||
fs.writeFileSync(p, bytes);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Mock fetch: serve `manifestBytes` for SHA256SUMS, its signature for *.sig. */
|
||||
function mockManifest(manifestBytes: Uint8Array, sigBytes = sign(manifestBytes)) {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : (input as URL).toString();
|
||||
const out = url.endsWith(".sig") ? sigBytes : manifestBytes;
|
||||
return { ok: true, arrayBuffer: async () => out.buffer } as Response;
|
||||
});
|
||||
}
|
||||
|
||||
const body = (lines: string, version = PRO_VERSION) =>
|
||||
enc(`version=${version}\n${lines}`);
|
||||
|
||||
it("passes when signature is valid and hash matches", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
mockManifest(body(`${hash} ${getArchiveName()}\n`));
|
||||
await expect(verifyProDownload(file, PRO_VERSION)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass Pro verification", async () => {
|
||||
const file = tmpFile(Buffer.from("a malicious pro binary"));
|
||||
const goodHash = createHash("sha256").update(Buffer.from("the real pro binary")).digest("hex");
|
||||
process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
|
||||
mockManifest(body(`${goodHash} ${getArchiveName()}\n`));
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
// The error TYPE is the contract the ensureBinary router branches on:
|
||||
// BinaryVerificationError => re-throw (never downgrade to free).
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/Checksum verification failed/);
|
||||
});
|
||||
|
||||
it("treats a failed manifest fetch as transient, not tampering", async () => {
|
||||
// A failed manifest FETCH must be a plain Error (router falls back to free),
|
||||
// NOT a BinaryVerificationError (which the router re-throws as a hard fail).
|
||||
const file = tmpFile(Buffer.from("x"));
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(BinaryVerificationError);
|
||||
});
|
||||
|
||||
it("fails on a signed manifest for the wrong version (downgrade)", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0"));
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/Version mismatch/);
|
||||
});
|
||||
|
||||
it("rejects a manifest tampered after signing", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
const good = body(`${hash} ${getArchiveName()}\n`);
|
||||
const sig = sign(good);
|
||||
const tampered = enc(new TextDecoder().decode(good).replace(getArchiveName(), "evil.tar.gz"));
|
||||
mockManifest(tampered, sig);
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/signature verification failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version binding", () => {
|
||||
it("reads the version= line", () => {
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user