| 1 | import assert from "node:assert/strict"; |
| 2 | import { createCipheriv, pbkdf2Sync } from "node:crypto"; |
| 3 | import { mkdirSync, mkdtempSync, readdirSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; |
| 6 | import { DatabaseSync } from "node:sqlite"; |
| 7 | import { test } from "node:test"; |
| 8 | import { BrowserControlStore, loadBrowserControlBootstrap, type BrowserSession } from "./browserControl.js"; |
| 9 | import { BrowserControlHost } from "./browserControlHost.js"; |
| 10 | import type { ChromeCookie } from "./chromeImport.js"; |
| 11 | |
| 12 | const log = { info: () => {}, warn: () => {}, error: () => {} }; |
| 13 | |
| 14 | function fakeSession() { |
| 15 | return { |
| 16 | certificateProcs: [] as (((request: unknown, callback: (result: number) => void) => void) | null)[], |
| 17 | storageClears: [] as ({ storages?: string[] } | undefined)[], |
| 18 | cacheClears: 0, |
| 19 | cookies: { set: async (_cookie: ChromeCookie) => {} }, |
| 20 | setCertificateVerifyProc(proc: ((request: unknown, callback: (result: number) => void) => void) | null) { |
| 21 | this.certificateProcs.push(proc); |
| 22 | }, |
| 23 | async clearCache() { |
| 24 | this.cacheClears++; |
| 25 | }, |
| 26 | async clearStorageData(options?: { storages?: string[] }) { |
| 27 | this.storageClears.push(options); |
| 28 | }, |
| 29 | }; |
| 30 | } |
| 31 | |
| 32 | function hostFor(root: string, overrides: Partial<Parameters<typeof buildHost>[0]> = {}) { |
| 33 | return buildHost({ root, ...overrides }); |
| 34 | } |
| 35 | |
| 36 | function buildHost(input: { |
| 37 | root: string; |
| 38 | platform?: NodeJS.Platform; |
| 39 | home?: string; |
| 40 | run?: (command: string, args: string[]) => Promise<string>; |
| 41 | list?: (path: string) => string[]; |
| 42 | sessions?: ReturnType<typeof fakeSession>[]; |
| 43 | pushed?: boolean[]; |
| 44 | }) { |
| 45 | const bootstrap = loadBrowserControlBootstrap(input.root); |
| 46 | const store = new BrowserControlStore(bootstrap.configPath, bootstrap); |
| 47 | const shared = fakeSession(); |
| 48 | const pushed = input.pushed ?? []; |
| 49 | const host = new BrowserControlHost({ |
| 50 | store, |
| 51 | sharedSession: () => shared as unknown as BrowserSession & { cookies: { set(cookie: ChromeCookie): Promise<void> } }, |
| 52 | log, |
| 53 | platform: input.platform ?? "darwin", |
| 54 | home: input.home ?? input.root, |
| 55 | env: {}, |
| 56 | run: input.run ?? (async () => "safe-storage-password"), |
| 57 | list: input.list ?? ((path: string) => readdirSync(path)), |
| 58 | onControlEnabled: (enabled) => void pushed.push(enabled), |
| 59 | }); |
| 60 | return { host, shared, pushed, store }; |
| 61 | } |
| 62 | |
| 63 | function chromeHome(): string { |
| 64 | const home = mkdtempSync(join(tmpdir(), "reasonix-chrome-home-")); |
| 65 | const profile = join(home, "Library", "Application Support", "Google", "Chrome", "Default"); |
| 66 | mkdirSync(profile, { recursive: true }); |
| 67 | const key = pbkdf2Sync("safe-storage-password", "saltysalt", 1003, 16, "sha1"); |
| 68 | const cipher = createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); |
| 69 | const value = Buffer.concat([Buffer.from("v10"), cipher.update("token", "utf8"), cipher.final()]); |
| 70 | const database = new DatabaseSync(join(profile, "Cookies")); |
| 71 | database.exec(`CREATE TABLE cookies ( |
| 72 | host_key TEXT, name TEXT, encrypted_value BLOB, path TEXT, |
| 73 | expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, samesite INTEGER)`); |
| 74 | database |
| 75 | .prepare("INSERT INTO cookies VALUES (?, ?, ?, ?, ?, ?, ?, ?)") |
| 76 | .run(".example.test", "sid", value, "/", 13_600_000_000_000_000, 1, 1, 1); |
| 77 | database.close(); |
| 78 | return home; |
| 79 | } |
| 80 | |
| 81 | test("the control switch persists and is pushed to the host", async () => { |
| 82 | const root = mkdtempSync(join(tmpdir(), "reasonix-browser-control-")); |
| 83 | const { host, pushed } = hostFor(root); |
| 84 | assert.equal(host.state().controlEnabled, true); |
| 85 | assert.deepEqual(pushed, []); |
| 86 | await host.setControlEnabled(false); |
| 87 | assert.equal(host.state().controlEnabled, false); |
| 88 | assert.deepEqual(pushed, [false]); |
| 89 | assert.equal(new BrowserControlStore(join(root, "browser-control.json"), loadBrowserControlBootstrap(root)).current.controlEnabled, false); |
| 90 | }); |
| 91 | |
| 92 | test("the certificate policy follows every guest session, including later ones", async () => { |
| 93 | const root = mkdtempSync(join(tmpdir(), "reasonix-browser-control-")); |
| 94 | const { host } = hostFor(root); |
| 95 | const first = fakeSession(); |
| 96 | host.trackSession("persist:browser", first as unknown as BrowserSession); |
| 97 | assert.deepEqual(first.certificateProcs, [null]); |
| 98 | |
| 99 | await host.setIgnoreCertificateErrors(true); |
| 100 | assert.equal(typeof first.certificateProcs[1], "function"); |
| 101 | |
| 102 | const second = fakeSession(); |
| 103 | host.trackSession("temp:tab-2", second as unknown as BrowserSession); |
| 104 | assert.equal(typeof second.certificateProcs[0], "function"); |
| 105 | |
| 106 | await host.setIgnoreCertificateErrors(false); |
| 107 | assert.deepEqual(first.certificateProcs[2], null); |
| 108 | assert.deepEqual(second.certificateProcs[1], null); |
| 109 | }); |
| 110 | |
| 111 | test("cache and data actions target the built-in browser partition", async () => { |
| 112 | const root = mkdtempSync(join(tmpdir(), "reasonix-browser-control-")); |
| 113 | const { host, shared } = hostFor(root); |
| 114 | await host.clearCache(); |
| 115 | await host.clearAllData(); |
| 116 | assert.equal(shared.cacheClears, 2); |
| 117 | assert.deepEqual(shared.storageClears, [{ storages: ["shadercache", "cachestorage", "serviceworkers"] }, undefined]); |
| 118 | }); |
| 119 | |
| 120 | test("import reports missing Chrome and missing profiles separately", async () => { |
| 121 | const empty = mkdtempSync(join(tmpdir(), "reasonix-browser-control-")); |
| 122 | const missing = hostFor(empty); |
| 123 | assert.deepEqual(await missing.host.importChromeLogin(), { ok: false, reason: "chrome-missing" }); |
| 124 | |
| 125 | const home = mkdtempSync(join(tmpdir(), "reasonix-browser-control-")); |
| 126 | mkdirSync(join(home, "Library", "Application Support", "Google", "Chrome"), { recursive: true }); |
| 127 | const noProfile = hostFor(home); |
| 128 | assert.deepEqual(await noProfile.host.importChromeLogin(), { ok: false, reason: "profile-not-found" }); |
| 129 | }); |
| 130 | |
| 131 | test("import copies cookies into the built-in browser session", async () => { |
| 132 | const home = chromeHome(); |
| 133 | const seen: ChromeCookie[] = []; |
| 134 | const { host, shared } = hostFor(home); |
| 135 | shared.cookies.set = async (cookie: ChromeCookie) => void seen.push(cookie); |
| 136 | assert.deepEqual(await host.importChromeLogin(), { ok: true, profile: "Default", cookies: 1, skipped: 0 }); |
| 137 | assert.equal(seen.length, 1); |
| 138 | assert.equal(seen[0].name, "sid"); |
| 139 | assert.equal(seen[0].value, "token"); |
| 140 | }); |
| 141 | |
| 142 | test("a denied keychain prompt becomes a typed outcome", async () => { |
| 143 | const home = chromeHome(); |
| 144 | const { host } = hostFor(home, { run: async () => Promise.reject(new Error("User interaction is not allowed.")) }); |
| 145 | assert.deepEqual(await host.importChromeLogin(), { ok: false, reason: "safe-storage-denied" }); |
| 146 | }); |
| 147 |