From 2f2ed32b46f08a12dabda68ada82c0dfc1982676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=84=B1?= Date: Tue, 7 Jul 2026 19:17:28 +0900 Subject: [PATCH] =?UTF-8?q?test(browse):=20auto-cookie=20persistence=20?= =?UTF-8?q?=E2=80=94=20unit,=20round-trip,=20launch,=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unit: opt-in gate, persistent-only/expiry/internal-network/allowlist filter, order-independent content hash, atomic-write helper, mkdir-atomic lock (acquire / same-pid contend / dead-pid reclaim / release). - Real Playwright round-trip: a persistent __Host- cookie saves and reloads via newContext({ storageState }); session/expired cookies are excluded; an unchanged set skips the second write. - Real BrowserManager.launch() integration: a pre-seeded auto-state file is restored into the launched context (simulates a daemon restart). - Static wiring tripwires: save gated on the opt-in flag, shutdown flush runs before the browser closes, and the crash path never checkpoints. Co-Authored-By: Claude Opus 4.8 --- browse/test/auto-cookie-persist.test.ts | 294 ++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 browse/test/auto-cookie-persist.test.ts diff --git a/browse/test/auto-cookie-persist.test.ts b/browse/test/auto-cookie-persist.test.ts new file mode 100644 index 000000000..0771c2623 --- /dev/null +++ b/browse/test/auto-cookie-persist.test.ts @@ -0,0 +1,294 @@ +/** + * Tests for opt-in automatic cookie persistence (auto-cookie-persist.ts). + * + * Unit tests cover the cookie filter (persistent-only, expiry, internal-network, + * allowlist), the content hash, the mkdir-atomic lock (acquire / contend / + * stale-reclaim / release), and the atomic-write helper. A Playwright + * round-trip test proves a persistent __Host- cookie is saved and restored via + * newContext({ storageState }), and that session/expired cookies are excluded. + * + * Static-grep tests pin the load-bearing wiring in server.ts / browser-manager.ts + * (opt-in gate, no-save-on-crash-path, shutdown flush before close) so a refactor + * can't silently regress them. + */ + +import { describe, test, expect, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Cookie } from 'playwright'; +import { chromium } from 'playwright'; +import type { BrowseConfig } from '../src/config'; +import { + isAutoCookiePersistEnabled, + parseDomainAllowlist, + filterPersistableCookies, + cookieSetHash, + computeProfileId, + acquireLock, + releaseLock, + loadAutoCookieState, + saveAutoCookieState, +} from '../src/auto-cookie-persist'; +import { writeSecureFileAtomic } from '../src/file-permissions'; + +const META = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); +const BM = fs.readFileSync(path.join(import.meta.dir, '../src/browser-manager.ts'), 'utf-8'); + +function tmpConfig(): BrowseConfig { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-cookie-')); + const stateDir = path.join(dir, '.gstack'); + fs.mkdirSync(stateDir, { recursive: true }); + return { + projectDir: dir, + stateDir, + stateFile: path.join(stateDir, 'browse.json'), + consoleLog: '', networkLog: '', dialogLog: '', auditLog: '', + }; +} + +const future = Math.floor(Date.now() / 1000) + 60 * 24 * 60 * 60; +function cookie(over: Partial = {}): Cookie { + return { + name: 'c', value: 'v', domain: 'example.com', path: '/', + expires: future, httpOnly: true, secure: true, sameSite: 'Lax', ...over, + } as Cookie; +} + +const savedEnv = { ...process.env }; +afterEach(() => { + process.env = { ...savedEnv }; +}); + +describe('opt-in gate', () => { + test('enabled only for truthy flag values', () => { + for (const v of ['1', 'true', 'TRUE', 'yes', 'on']) { + expect(isAutoCookiePersistEnabled({ BROWSE_AUTO_COOKIE_PERSIST: v })).toBe(true); + } + for (const v of [undefined, '', '0', 'false', 'no', 'off']) { + expect(isAutoCookiePersistEnabled({ BROWSE_AUTO_COOKIE_PERSIST: v as any })).toBe(false); + } + }); + + test('loadAutoCookieState returns null when disabled', () => { + const cfg = tmpConfig(); + delete process.env.BROWSE_AUTO_COOKIE_PERSIST; + expect(loadAutoCookieState(cfg)).toBeNull(); + }); + + test('saveAutoCookieState is a no-op when disabled', async () => { + const cfg = tmpConfig(); + delete process.env.BROWSE_AUTO_COOKIE_PERSIST; + const res = await saveAutoCookieState(cfg, null, null); + expect(res.wrote).toBe(false); + expect(fs.existsSync(path.join(cfg.stateDir, 'browse-auto-cookies'))).toBe(false); + }); +}); + +describe('cookie filter', () => { + test('keeps persistent, drops session cookies', () => { + const kept = filterPersistableCookies([cookie(), cookie({ name: 's', expires: -1 })], null); + expect(kept.map(c => c.name)).toEqual(['c']); + }); + + test('drops already-expired cookies', () => { + const past = Math.floor(Date.now() / 1000) - 10; + const kept = filterPersistableCookies([cookie({ name: 'old', expires: past }), cookie()], null); + expect(kept.map(c => c.name)).toEqual(['c']); + }); + + test('rejects internal-network domains', () => { + const kept = filterPersistableCookies([ + cookie({ name: 'lh', domain: 'localhost' }), + cookie({ name: 'meta', domain: '169.254.169.254' }), + cookie({ name: 'int', domain: 'foo.internal' }), + cookie({ name: 'ok', domain: 'example.com' }), + ], null); + expect(kept.map(c => c.name)).toEqual(['ok']); + }); + + test('applies host allowlist incl. leading-wildcard + apex', () => { + const cookies = [ + cookie({ name: 'a', domain: 'example.com' }), + cookie({ name: 'b', domain: 'sub.example.com' }), + cookie({ name: 'c', domain: 'other.com' }), + ]; + const kept = filterPersistableCookies(cookies, ['*.example.com']); + expect(kept.map(c => c.name).sort()).toEqual(['a', 'b']); // apex + subdomain, not other.com + }); + + test('parseDomainAllowlist splits and lowercases; empty ⇒ null', () => { + expect(parseDomainAllowlist({ BROWSE_AUTO_COOKIE_DOMAINS: 'A.com, *.B.com' })) + .toEqual(['a.com', '*.b.com']); + expect(parseDomainAllowlist({ BROWSE_AUTO_COOKIE_DOMAINS: '' })).toBeNull(); + expect(parseDomainAllowlist({})).toBeNull(); + }); +}); + +describe('content hash', () => { + test('order-independent', () => { + const a = cookie({ name: 'a', domain: 'a.com' }); + const b = cookie({ name: 'b', domain: 'b.com' }); + expect(cookieSetHash([a, b])).toBe(cookieSetHash([b, a])); + }); + test('changes when a value changes', () => { + const a = cookie({ name: 'a', value: '1' }); + const a2 = cookie({ name: 'a', value: '2' }); + expect(cookieSetHash([a])).not.toBe(cookieSetHash([a2])); + }); +}); + +describe('atomic write helper', () => { + test('writes content and leaves no temp file', () => { + const cfg = tmpConfig(); + const f = path.join(cfg.stateDir, 'atomic.json'); + writeSecureFileAtomic(f, JSON.stringify({ ok: 1 })); + expect(JSON.parse(fs.readFileSync(f, 'utf-8'))).toEqual({ ok: 1 }); + const leftovers = fs.readdirSync(cfg.stateDir).filter(n => n.endsWith('.tmp')); + expect(leftovers).toEqual([]); + }); +}); + +describe('workspace lock', () => { + test('acquire, contend (same process re-acquire fails), release', () => { + const cfg = tmpConfig(); + expect(acquireLock(cfg)).toBe(true); + // Second acquire while a live pid (this process) owns it → refused. + expect(acquireLock(cfg)).toBe(false); + releaseLock(cfg); + // After release the dir is gone and we can re-acquire. + expect(acquireLock(cfg)).toBe(true); + releaseLock(cfg); + }); + + test('reclaims a lock owned by a dead pid', () => { + const cfg = tmpConfig(); + const lockDir = path.join(cfg.stateDir, 'browse-auto-cookies', `${computeProfileId(cfg)}.lock`); + fs.mkdirSync(lockDir, { recursive: true }); + // PID 2^31-1 is not a live process → stale, reclaimable. + fs.writeFileSync(path.join(lockDir, 'owner.json'), JSON.stringify({ pid: 2147483646, startedAt: 'x' })); + expect(acquireLock(cfg)).toBe(true); + releaseLock(cfg); + }); +}); + +describe('save → load round-trip (real Playwright)', () => { + test('persistent __Host- cookie survives; session/expired excluded', async () => { + process.env.BROWSE_AUTO_COOKIE_PERSIST = '1'; + delete process.env.BROWSE_AUTO_COOKIE_DOMAINS; + const cfg = tmpConfig(); + const origin = 'https://luseed.example.com'; + const browser = await chromium.launch({ headless: true }); + try { + const ctx = await browser.newContext(); + await ctx.addCookies([ + { name: '__Host-lsd', value: 'tok.raw', url: origin, httpOnly: true, secure: true, sameSite: 'Lax', expires: future }, + { name: 'authjs.session-token', value: 'sess', url: origin, httpOnly: true, secure: true, sameSite: 'Lax', expires: future }, + { name: 'ephem', value: 'x', url: origin, httpOnly: false, secure: true, sameSite: 'Lax' }, // session + ]); + const res = await saveAutoCookieState(cfg, ctx, null); + await ctx.close(); + expect(res.wrote).toBe(true); + expect(res.count).toBe(2); // ephem (session) filtered out + + const loaded = loadAutoCookieState(cfg); + expect(loaded).not.toBeNull(); + const names = loaded!.cookies.map(c => c.name).sort(); + expect(names).toEqual(['__Host-lsd', 'authjs.session-token']); + + // The restored storageState actually rehydrates a fresh context. + const ctx2 = await browser.newContext({ storageState: loaded! }); + const back = await ctx2.cookies(); + await ctx2.close(); + expect(back.some(c => c.name === '__Host-lsd')).toBe(true); + } finally { + await browser.close(); + } + }); + + test('unchanged cookie set skips the second write (hash short-circuit)', async () => { + process.env.BROWSE_AUTO_COOKIE_PERSIST = '1'; + const cfg = tmpConfig(); + const origin = 'https://luseed.example.com'; + const browser = await chromium.launch({ headless: true }); + try { + const ctx = await browser.newContext(); + await ctx.addCookies([{ name: '__Host-lsd', value: 'tok', url: origin, httpOnly: true, secure: true, sameSite: 'Lax', expires: future }]); + const first = await saveAutoCookieState(cfg, ctx, null); + expect(first.wrote).toBe(true); + const second = await saveAutoCookieState(cfg, ctx, first.hash); + expect(second.wrote).toBe(false); // identical set → no write + await ctx.close(); + } finally { + await browser.close(); + } + }); +}); + +describe('BrowserManager.launch() integration (real browser + real daemon config)', () => { + test('launch() restores a persisted __Host- cookie into the fresh context', async () => { + process.env.BROWSE_AUTO_COOKIE_PERSIST = '1'; + delete process.env.BROWSE_AUTO_COOKIE_DOMAINS; + const cfg = tmpConfig(); + // launch() calls resolveConfig() internally, which honors BROWSE_STATE_FILE — + // point it at this isolated workspace. + process.env.BROWSE_STATE_FILE = cfg.stateFile; + + // Pre-seed the auto-state file exactly as a prior daemon's shutdown flush + // would have (persistent __Host- cookie for an https origin). + const origin = 'https://luseed.example.com'; + const browser0 = await chromium.launch({ headless: true }); + const seedCtx = await browser0.newContext(); + await seedCtx.addCookies([ + { name: '__Host-lsd', value: 'device.token', url: origin, httpOnly: true, secure: true, sameSite: 'Lax', expires: future }, + ]); + const wrote = await saveAutoCookieState(cfg, seedCtx, null); + await seedCtx.close(); + await browser0.close(); + expect(wrote.wrote).toBe(true); + + // Launch a real BrowserManager against the same workspace — it should load + // the file and seed the new context, simulating a daemon restart. + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + try { + await bm.launch(); + const ctx = bm.getContext(); + expect(ctx).not.toBeNull(); + const cookies = await ctx!.cookies(); + expect(cookies.some(c => c.name === '__Host-lsd' && c.value === 'device.token')).toBe(true); + } finally { + await bm.close(); + } + }, 30_000); // real browser launch can take >5s (matches handoff integration timing) +}); + +describe('wiring (static)', () => { + test('save is gated on the opt-in flag at daemon start', () => { + expect(META).toContain('isAutoCookiePersistEnabled()'); + expect(META).toContain('acquireAutoCookieLock(config)'); + }); + + test('final checkpoint runs in shutdown BEFORE the browser closes', () => { + const shutIdx = META.indexOf('async function shutdown('); + const flushIdx = META.indexOf('await autoCookieCheckpoint();', shutIdx); + const closeIdx = META.indexOf('await cfgBrowserManager.close();', shutIdx); + expect(flushIdx).toBeGreaterThan(shutIdx); + expect(closeIdx).toBeGreaterThan(flushIdx); // flush precedes close + }); + + test('crash path (emergencyCleanup) does NOT checkpoint cookies', () => { + const start = META.indexOf('function emergencyCleanup()'); + const end = META.indexOf('\n}', start); + const body = META.slice(start, end); + expect(body).not.toContain('autoCookieCheckpoint'); + expect(body).not.toContain('saveAutoCookieState'); + }); + + test('launch() seeds the context from persisted cookies before newContext', () => { + const loadIdx = BM.indexOf('loadAutoCookieState(cfg)'); + const newCtxIdx = BM.indexOf('this.context = await this.browser.newContext(contextOptions);'); + expect(loadIdx).toBeGreaterThan(0); + expect(newCtxIdx).toBeGreaterThan(loadIdx); // restore assigned into contextOptions before use + }); +});