storebound/tests/console-session.test.ts

270 lines
18 KiB
TypeScript

// storebound console fill: which browser it uses. Pure parts (data dir, profile pick, file selection, cookie host
// filter, DevToolsActivePort, the opt-in flag, the browser plan, Google's rejected page, the launch switches) and the
// session copy on throwaway folders (cleanup on success and on error). Safety: the default run never copies Google
// (or Apple) cookies; the opt-in copy of Google cookies warns about device-bound sessions. Integration (real Google
// Chrome, throwaway profiles, a local fixture site): the window storebound starts has navigator.webdriver false and no
// automation switch on its real command line (chrome://version); the opt-in copy carries a cookie of the source
// profile and drops another host's. It never touches the real Chrome profile or a store site; it skips when Google
// Chrome is not installed or not on macOS.
import { afterAll, describe, expect, test } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { APPLE_COPY_NOTE, AUTOMATION_SWITCHES, browserPlan, chromeDataDir, consoleChromeArgs, CONSOLE_HOSTS, cookieApplies, copySession, devToolsEndpoint, filterCookies, GOOGLE_COPY_WARNING, GOOGLE_REJECTED, googleRejected, NEVER_COPY, openConsoleBrowser, parseUseChromeSession, pickProfile, PROVIDER_HOSTS, sessionFiles } from '../cli/console/session';
import { capped, CLOSE_TIMEOUT_MS, LAUNCH_TIMEOUT_MS } from './helpers/browser';
const scratch = mkdtempSync(join(tmpdir(), 'sb-session-test-'));
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
const tmp = (name: string) => { const d = join(scratch, name); mkdirSync(d, { recursive: true }); return d; };
async function cookieDb(path: string, hosts: string[]): Promise<void> {
const { Database } = await import('bun:sqlite');
const db = new Database(path);
db.exec('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB)');
const ins = db.prepare('INSERT INTO cookies VALUES (?, ?, ?, ?)');
for (const h of hosts) ins.run(h, 'c', '', new Uint8Array([1]));
db.close();
}
/** A fake Chrome user data dir with the session files and the ones that must never be copied. */
async function fakeChrome(name: string, profile = 'Default'): Promise<string> {
const d = tmp(name);
writeFileSync(join(d, 'Local State'), JSON.stringify({ profile: { last_used: profile } }));
mkdirSync(join(d, profile), { recursive: true });
await cookieDb(join(d, profile, 'Cookies'), ['.apple.com', 'appstoreconnect.apple.com', '.google.com', 'play.google.com', 'accounts.google.com', '.youtube.com', 'mail.google.com', '.example.com', 'news.example.org']);
writeFileSync(join(d, profile, 'Cookies-journal'), '');
for (const f of ['Login Data', 'History', 'Web Data', 'Bookmarks', 'Preferences']) writeFileSync(join(d, profile, f), 'x');
mkdirSync(join(d, profile, 'Extensions'), { recursive: true });
return d;
}
describe('pure parts', () => {
test('Chrome user data dir per OS', () => {
expect(chromeDataDir('darwin', '/Users/you')).toBe('/Users/you/Library/Application Support/Google/Chrome');
expect(chromeDataDir('linux', '/home/you', {})).toBe('/home/you/.config/google-chrome');
expect(chromeDataDir('win32', 'C:\\Users\\you', { LOCALAPPDATA: 'L' })).toContain('User Data');
});
test('profile: --chrome-profile, else last used, else Default; no path tricks', () => {
expect(pickProfile(null)).toBe('Default');
expect(pickProfile('{"profile":{"last_used":"Profile 2"}}')).toBe('Profile 2');
expect(pickProfile('{"profile":{"last_used":"../x"}}')).toBe('Default');
expect(pickProfile('not json', 'Profile 1')).toBe('Profile 1');
expect(() => pickProfile(null, '../Default')).toThrow('--chrome-profile');
});
test('files: Local State and the cookie store only, never passwords, history or extensions', () => {
const all = new Set(['Local State', 'Default/Cookies', 'Default/Cookies-journal', 'Default/Login Data', 'Default/History', 'Default/Network/Cookies']);
expect(sessionFiles('Default', r => all.has(r))).toEqual([
['Local State', 'Local State'], ['Default/Cookies', 'Default/Cookies'], ['Default/Cookies-journal', 'Default/Cookies-journal'], ['Default/Network/Cookies', 'Default/Network/Cookies'],
]);
expect(sessionFiles('Profile 1', r => r === 'Profile 1/Cookies')).toEqual([['Profile 1/Cookies', 'Default/Cookies']]);
for (const f of ['Default/Login Data', 'Default/History', 'Default/Extensions', 'Default/Web Data']) expect(NEVER_COPY.test(f)).toBe(true);
expect(NEVER_COPY.test('Default/Cookies')).toBe(false);
});
test('cookie hosts: only domains the consoles get', () => {
for (const h of ['.apple.com', 'appstoreconnect.apple.com', 'idmsa.apple.com', '.google.com', 'play.google.com', 'accounts.google.com']) expect(cookieApplies(h)).toBe(true);
for (const h of ['.youtube.com', 'mail.google.com', 'developer.apple.com', '.example.com', 'apple.com.example.org', '', '.com']) expect(cookieApplies(h)).toBe(false);
expect(CONSOLE_HOSTS).toContain('play.google.com');
});
test('DevToolsActivePort -> a local endpoint', () => {
const d = tmp('devtools');
expect(devToolsEndpoint(d)).toBeNull();
writeFileSync(join(d, 'DevToolsActivePort'), '9333\n/devtools/browser/abc\n');
expect(devToolsEndpoint(d)).toBe('http://127.0.0.1:9333');
writeFileSync(join(d, 'DevToolsActivePort'), 'nope');
expect(devToolsEndpoint(d)).toBeNull();
});
});
describe('Google session safety (device-bound cookies)', () => {
const none = { storeboundChrome: null, chrome: null, chromium: null };
const copies = () => readdirSync(tmpdir()).filter(f => f.startsWith('storebound-chrome-')).length;
test('--use-chrome-session: google | apple only; absent = no copy', () => {
expect(parseUseChromeSession(undefined)).toEqual([]);
expect(parseUseChromeSession('google')).toEqual(['google']);
expect(parseUseChromeSession('Apple, google,apple')).toEqual(['apple', 'google']);
expect(() => parseUseChromeSession(true)).toThrow('--use-chrome-session');
expect(() => parseUseChromeSession('all')).toThrow('--use-chrome-session');
});
test('plan: no copy by default for either store; an opt-in copies only the form\'s own provider', () => {
for (const store of ['play', 'apple'] as const) expect(browserPlan({ store, platform: 'darwin' }, true).copy).toBeNull();
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['google'] }, true).copy).toBe('google');
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['apple'] }, true).copy).toBeNull();
expect(browserPlan({ store: 'apple', platform: 'darwin', useChromeSession: ['google'] }, true).copy).toBeNull();
expect(browserPlan({ store: 'apple', platform: 'darwin', useChromeSession: ['apple'] }, true).copy).toBe('apple');
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['google'], fresh: true }, true).copy).toBeNull();
expect(browserPlan({ store: 'play', platform: 'linux', useChromeSession: ['google'] }, true).copy).toBeNull();
// an Apple copy never carries a Google cookie and the other way round
expect(PROVIDER_HOSTS.apple.some(h => h.includes('google'))).toBe(false);
expect(PROVIDER_HOSTS.google.some(h => h.includes('apple'))).toBe(false);
});
test('the default run makes no copy of the owner\'s Chrome cookies (play and apple)', async () => {
const src = await fakeChrome('chrome-default');
for (const store of ['play', 'apple'] as const) {
const said: string[] = [];
const before = copies();
const e = await openConsoleBrowser(scratch, { store, profileDir: tmp(`own-${store}`), say: m => said.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(x => x as Error);
expect((e as Error).message).toContain('no usable browser'); // it went straight to storebound's own profile
expect(said.join('\n')).not.toMatch(/copied|Chrome sign-in|WARNING/);
expect(copies()).toBe(before);
}
});
test('the opt-in Google copy warns about device-bound sessions first; the Apple one says its note', async () => {
const src = await fakeChrome('chrome-optin');
const said: string[] = [];
await openConsoleBrowser(scratch, { store: 'play', useChromeSession: ['google'], profileDir: tmp('own-optin'), say: m => said.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(() => null);
expect(said[0]).toBe(GOOGLE_COPY_WARNING);
expect(GOOGLE_COPY_WARNING).toContain('sign you out of your real Chrome');
expect(said.join('\n')).toContain('could not use your Chrome sign-in'); // no Google Chrome here: the copy is removed again
const saidA: string[] = [];
await openConsoleBrowser(scratch, { store: 'apple', useChromeSession: ['apple'], profileDir: tmp('own-optin-a'), say: m => saidA.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(() => null);
expect(saidA[0]).toBe(APPLE_COPY_NOTE);
});
test('Google\'s rejected page is recognised (and only that page)', () => {
for (const u of ['https://accounts.google.com/v3/signin/rejected?continue=x', 'https://accounts.google.com/signin/rejected', 'https://accounts.google.com/signin/v2/rejected']) expect(googleRejected(u)).toBe(true);
for (const u of ['https://accounts.google.com/v3/signin/accountchooser', 'https://accounts.google.com/v3/signin/identifier', 'https://play.google.com/console/signin/rejected', 'https://example.com/v3/signin/rejected', 'not a url']) expect(googleRejected(u)).toBe(false);
expect(GOOGLE_REJECTED).toBe('Google refused sign-in in this window (/signin/rejected). storebound uses a normal Chrome window; try again, or sign in at accounts.google.com first in this window.');
});
test('launch switches: no automation switch, webdriver off, session restore, a DevTools port the OS picks', () => {
const a = consoleChromeArgs('/p');
for (const s of AUTOMATION_SWITCHES) expect(a.some(x => x.startsWith(s))).toBe(false);
expect(a).toContain('--disable-blink-features=AutomationControlled');
expect(a).toContain('--restore-last-session');
expect(a).toContain('--remote-debugging-port=0');
expect(a).toContain('--user-data-dir=/p');
expect(a.some(x => /user-agent/i.test(x))).toBe(false);
expect(consoleChromeArgs('/p', { headless: true })).toContain('--headless=new');
});
});
describe('session copy', () => {
test('copies only the session files, filters the cookies, cleans up', async () => {
const src = await fakeChrome('chrome-a');
const root = tmp('copies-a');
const c = await copySession(src, { tmpRoot: root });
expect(c.profile).toBe('Default');
expect(c.files).toEqual(['Local State', 'Default/Cookies', 'Default/Cookies-journal']);
for (const f of readdirSync(join(c.dir, 'Default'))) expect(['Cookies', 'Cookies-journal']).toContain(f);
expect(readdirSync(c.dir).sort()).toEqual(['Default', 'Local State']);
for (const f of ['Login Data', 'History', 'Web Data', 'Bookmarks', 'Preferences', 'Extensions']) expect(existsSync(join(c.dir, 'Default', f))).toBe(false);
expect(c.filtered).toBe(true);
expect(c.kept).toBe(5);
const { Database } = await import('bun:sqlite');
const db = new Database(join(c.dir, 'Default', 'Cookies'), { readonly: true });
expect((db.query('SELECT host_key AS h FROM cookies ORDER BY h').all() as { h: string }[]).map(r => r.h)).toEqual(['.apple.com', '.google.com', 'accounts.google.com', 'appstoreconnect.apple.com', 'play.google.com']);
db.close();
c.cleanup(); c.cleanup();
expect(existsSync(c.dir)).toBe(false);
expect(readdirSync(root)).toEqual([]);
// the source is untouched
expect(existsSync(join(src, 'Default', 'Login Data'))).toBe(true);
});
test('a named profile lands in Default of the copy', async () => {
const src = await fakeChrome('chrome-b', 'Profile 3');
const c = await copySession(src, { tmpRoot: tmp('copies-b'), profile: 'Profile 3' });
expect(c.files).toContain('Profile 3/Cookies');
expect(existsSync(join(c.dir, 'Default', 'Cookies'))).toBe(true);
c.cleanup();
});
test('an error removes the temporary profile before it is thrown', async () => {
const src = tmp('chrome-broken');
mkdirSync(join(src, 'Default', 'Cookies'), { recursive: true }); // a folder where the file should be: the copy fails
const root = tmp('copies-c');
await expect(copySession(src, { tmpRoot: root })).rejects.toThrow();
expect(readdirSync(root)).toEqual([]);
await expect(copySession(tmp('chrome-empty'), { tmpRoot: root })).rejects.toThrow('no Chrome cookie store');
expect(readdirSync(root)).toEqual([]);
});
test('an unreadable cookie file is copied whole and the log says so (filtered: false)', async () => {
const src = tmp('chrome-notdb');
writeFileSync(join(src, 'Local State'), '{}');
mkdirSync(join(src, 'Default'));
writeFileSync(join(src, 'Default', 'Cookies'), 'not a database');
const c = await copySession(src, { tmpRoot: tmp('copies-d') });
expect(c.filtered).toBe(false);
c.cleanup();
expect(await filterCookies(join(scratch, 'none.db')).catch(() => 'err')).toBe('err');
});
});
// ---- integration: real Google Chrome, throwaway profiles, a local fixture site ----
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const hasChrome = process.platform === 'darwin' && existsSync(CHROME);
if (!hasChrome) console.warn('console-session integration test skipped: Google Chrome is not installed (or not macOS)');
describe.skipIf(!hasChrome)('copy mode with real Chrome (throwaway profile, local site)', () => {
test.concurrent('a cookie set in the source profile is there in the copy; another host\'s is filtered out; the copy is deleted on close', async () => {
let chromium: any;
try { chromium = (await import('playwright')).chromium; } catch { console.warn('skipped: playwright not installed'); return; }
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
const u = new URL(req.url);
if (u.pathname === '/set') return new Response('set', { headers: { 'set-cookie': 'sb_test=1; Max-Age=3600; Path=/' } });
return new Response(`cookie: ${/sb_test=1/.test(req.headers.get('cookie') ?? '') ? 'yes' : 'no'}`);
} });
const site = `http://127.0.0.1:${server.port}`, other = `http://localhost:${server.port}`;
const src = tmp('chrome-real');
// two real Chrome launches: SLOW; launch timeouts raised like the shared browser's (tests/helpers/browser.ts), and
// whatever is still open is closed in `finally`, so a failed assertion leaves no Chrome running
let seed: any = null, b: any = null;
try {
const opts = { channel: 'chrome', headless: true, timeout: LAUNCH_TIMEOUT_MS, ignoreDefaultArgs: ['--use-mock-keychain', '--password-store=basic'] };
seed = await chromium.launchPersistentContext(src, opts);
const p0 = seed.pages()[0] ?? await seed.newPage();
await p0.goto(`${site}/set`); await p0.goto(`${other}/set`);
await seed.close();
seed = null;
const said: string[] = [];
b = await openConsoleBrowser(join(import.meta.dir, '..'), {
store: 'apple', useChromeSession: ['apple'], profileDir: tmp('own-profile'), say: m => said.push(m), chromeDir: src, hosts: ['127.0.0.1'], headless: true, platform: 'darwin', launchTimeoutMs: LAUNCH_TIMEOUT_MS,
});
expect(b.mode).toBe('copy');
expect(said[0]).toBe(APPLE_COPY_NOTE);
expect(said.join('\n')).toContain('your Chrome sign-in');
expect(said.join('\n')).not.toContain('sb_test');
expect(b.tempDir && existsSync(b.tempDir)).toBe(true);
await b.page.goto(`${site}/check`);
expect(await b.page.textContent('body')).toBe('cookie: yes');
await b.page.goto(`${other}/check`);
expect(await b.page.textContent('body')).toBe('cookie: no');
await b.close();
await b.closed;
expect(existsSync(b.tempDir!)).toBe(false);
} finally {
if (seed) await capped(seed.close(), CLOSE_TIMEOUT_MS);
if (b && !b.isClosed()) { await capped(b.close(), CLOSE_TIMEOUT_MS); b.cleanup(); }
server.stop(true);
}
}, 2 * LAUNCH_TIMEOUT_MS); // bounded by the two launches; ~3 s on an idle machine
});
describe.skipIf(!hasChrome)('the console window has no automation markers (real Chrome, throwaway profile, local page)', () => {
test.concurrent('navigator.webdriver is false and the real command line has no --enable-automation', async () => {
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response(
'<p id="r"></p><script>document.getElementById("r").textContent = JSON.stringify({ webdriver: navigator.webdriver, ua: navigator.userAgent })</script>',
{ headers: { 'content-type': 'text/html' } }) });
let b: any = null;
try {
b = await openConsoleBrowser(join(import.meta.dir, '..'), {
store: 'play', profileDir: tmp('own-webdriver'), say: () => {}, headless: true, platform: 'darwin', chromeDir: tmp('no-chrome-here'), launchTimeoutMs: LAUNCH_TIMEOUT_MS,
});
expect(b.mode).toBe('profile');
for (const s of AUTOMATION_SWITCHES) expect(b.args.some((x: string) => x.startsWith(s))).toBe(false);
await b.page.goto(`http://127.0.0.1:${server.port}/`);
const r = JSON.parse(await b.page.textContent('#r'));
expect(r.webdriver).toBe(false);
expect(r.ua).toContain('Chrome/');
await b.page.goto('chrome://version');
const cmd: string = await b.page.textContent('#command_line');
expect(cmd).toContain('--user-data-dir=');
expect(cmd).not.toContain('--enable-automation');
expect(cmd).not.toContain('--remote-debugging-pipe');
expect(cmd).toContain('--disable-blink-features=AutomationControlled');
await b.close();
await b.closed;
expect(b.isClosed()).toBe(true);
} finally {
if (b && !b.isClosed()) await capped(b.close(), CLOSE_TIMEOUT_MS);
server.stop(true);
}
}, 2 * LAUNCH_TIMEOUT_MS);
});