mirror of https://github.com/garrytan/gstack.git
343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
/**
|
|
* 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');
|
|
const PICKER = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-routes.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> = {}): 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: 'lh2', domain: 'LOCALHOST' }),
|
|
cookie({ name: 'meta', domain: '169.254.169.254' }),
|
|
cookie({ name: 'int', domain: 'foo.internal' }),
|
|
cookie({ name: 'int2', domain: 'Foo.INTERNAL' }),
|
|
cookie({ name: 'ok', domain: 'example.com' }),
|
|
], null);
|
|
expect(kept.map(c => c.name)).toEqual(['ok']);
|
|
});
|
|
|
|
test('rejects malformed expires values', () => {
|
|
const kept = filterPersistableCookies([
|
|
cookie({ name: 'nan', expires: Number.NaN }),
|
|
cookie({ name: 'inf', expires: Number.POSITIVE_INFINITY }),
|
|
cookie({ name: 'ok' }),
|
|
], 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);
|
|
});
|
|
|
|
test('reclaims a lock whose pid was reused by a newer process', () => {
|
|
if (process.platform === 'win32') return;
|
|
const cfg = tmpConfig();
|
|
const lockDir = path.join(cfg.stateDir, 'browse-auto-cookies', `${computeProfileId(cfg)}.lock`);
|
|
fs.mkdirSync(lockDir, { recursive: true });
|
|
const proc = Bun.spawn(['sleep', '5'], { stdout: 'ignore', stderr: 'ignore' });
|
|
try {
|
|
fs.writeFileSync(path.join(lockDir, 'owner.json'), JSON.stringify({
|
|
pid: proc.pid,
|
|
startedAt: '1970-01-01T00:00:00.000Z',
|
|
}));
|
|
expect(acquireLock(cfg)).toBe(true);
|
|
releaseLock(cfg);
|
|
} finally {
|
|
proc.kill();
|
|
}
|
|
});
|
|
});
|
|
|
|
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
|
|
});
|
|
|
|
test('debounced command checkpoint runs after dispatch and covers real mutators', () => {
|
|
expect(META).toContain('WRITE_COMMANDS.has(command)');
|
|
expect(META).toContain("'js', 'eval'");
|
|
expect(META).toContain("'chain', 'state', 'newtab', 'tab-each'");
|
|
expect(META).not.toContain("'set-cookie'");
|
|
const canonicalIdx = META.indexOf('const command = canonicalizeCommand(rawCommand);');
|
|
const dispatchIdx = META.indexOf('if (READ_COMMANDS.has(command))', canonicalIdx);
|
|
const scheduleIdx = META.indexOf('// Auto-cookie checkpoint (opt-in): schedule after', dispatchIdx);
|
|
expect(dispatchIdx).toBeGreaterThan(canonicalIdx);
|
|
expect(scheduleIdx).toBeGreaterThan(dispatchIdx);
|
|
});
|
|
|
|
test('cookie picker imports/removals schedule auto-cookie persistence', () => {
|
|
expect(META).toContain('handleCookiePickerRoute(url, req, browserManager, authToken, scheduleAutoCookieCheckpoint)');
|
|
expect(PICKER).toContain('onCookieMutation?: () => void');
|
|
expect(PICKER.match(/onCookieMutation\?\.\(\)/g)?.length).toBe(2);
|
|
});
|
|
});
|