mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-28 07:32:14 +02:00
v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { describe, test, expect, afterAll, setDefaultTimeout } from 'bun:test';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
|
||||
// Every test here spawnSync's a `node` child; Windows CI cold-start (AV scan,
|
||||
// first-touch of node.exe) alone can blow bun's 5s default — observed 5,007ms
|
||||
@@ -204,6 +206,118 @@ describe('bun-polyfill', () => {
|
||||
expect(result.stdout.toString().trim()).toBe('1048576:0');
|
||||
}, 15000);
|
||||
|
||||
test('cancelled replay readers release inherited pipes after the direct child exits', () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'polyfill-cancel-')));
|
||||
const marker = path.join(root, 'descendant.pid');
|
||||
const pidPath = path.join(root, 'spawned.pid');
|
||||
expect(fs.realpathSync(root)).toBe(root);
|
||||
try {
|
||||
const descendantScript = `
|
||||
const fs = require('node:fs');
|
||||
fs.writeSync(1, 'fixture-stdout');
|
||||
fs.writeSync(2, 'fixture-stderr');
|
||||
fs.writeFileSync(${JSON.stringify(marker + '.tmp')}, JSON.stringify({ pid: process.pid, stdout: true, stderr: true }));
|
||||
fs.renameSync(${JSON.stringify(marker + '.tmp')}, ${JSON.stringify(marker)});
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
const childScript = `
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const descendant = spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true, detached: process.platform === 'win32' });
|
||||
fs.writeFileSync(${JSON.stringify(pidPath)}, String(descendant.pid));
|
||||
const deadline = Date.now() + 5000;
|
||||
const ready = () => {
|
||||
if (fs.existsSync(${JSON.stringify(marker)})) process.exit(0);
|
||||
if (descendant.exitCode !== null || Date.now() >= deadline) process.exit(1);
|
||||
setTimeout(ready, 10);
|
||||
};
|
||||
ready();
|
||||
`;
|
||||
const script = `
|
||||
const childProcess = require('node:child_process');
|
||||
const originalSpawn = childProcess.spawn;
|
||||
let direct;
|
||||
childProcess.spawn = (...args) => { direct = originalSpawn(...args); return direct; };
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
let stage = 'spawn';
|
||||
let directExitCode;
|
||||
let markerValid = false;
|
||||
let stdoutAck = false;
|
||||
let stderrAck = false;
|
||||
let descendantAlive = false;
|
||||
let checkErrorCode = null;
|
||||
(async () => {
|
||||
const proc = Bun.spawn([process.execPath, '-e', ${JSON.stringify(childScript)}],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
if (!direct) throw new Error('capture_missing');
|
||||
const stdout = proc.stdout.getReader();
|
||||
const stderr = proc.stderr.getReader();
|
||||
const stdoutRead = stdout.read();
|
||||
const stderrRead = stderr.read();
|
||||
stage = 'direct_exit';
|
||||
directExitCode = await new Promise((resolve, reject) => { direct.once('exit', resolve); direct.once('error', reject); });
|
||||
let readyPid;
|
||||
try {
|
||||
const fs = require('node:fs');
|
||||
const marker = JSON.parse(fs.readFileSync(${JSON.stringify(marker)}, 'utf8'));
|
||||
readyPid = marker.pid;
|
||||
markerValid = Number.isSafeInteger(readyPid) && readyPid > 0
|
||||
&& String(readyPid) === fs.readFileSync(${JSON.stringify(pidPath)}, 'utf8');
|
||||
stdoutAck = marker.stdout === true;
|
||||
stderrAck = marker.stderr === true;
|
||||
} catch (error) { checkErrorCode = typeof error.code === 'string' ? error.code : 'invalid_marker'; }
|
||||
if (markerValid) {
|
||||
try { process.kill(readyPid, 0); descendantAlive = true; }
|
||||
catch (error) { checkErrorCode = typeof error.code === 'string' ? error.code : 'liveness_error'; }
|
||||
}
|
||||
if (!markerValid || !stdoutAck || !stderrAck || !descendantAlive) throw new Error('descendant_not_ready');
|
||||
stage = 'pending_check';
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
let settled = false;
|
||||
proc.exited.then(() => { settled = true; });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (settled) throw new Error('Inherited pipes unexpectedly closed before cancellation');
|
||||
stage = 'cancel';
|
||||
await Promise.all([stdout.cancel(), stderr.cancel()]);
|
||||
const reads = await Promise.all([stdoutRead, stderrRead]);
|
||||
stage = 'await_exited';
|
||||
let timer;
|
||||
const code = await Promise.race([proc.exited, new Promise((_, reject) =>
|
||||
{ timer = setTimeout(() => reject(new Error('cancel did not settle exited')), 5000); })])
|
||||
.finally(() => clearTimeout(timer));
|
||||
console.log(JSON.stringify({ code, directExitCode, reads: reads.map(read => read.done), descendantAlive: (() => {
|
||||
try { process.kill(JSON.parse(require('node:fs').readFileSync(${JSON.stringify(marker)}, 'utf8')).pid, 0); return true; }
|
||||
catch { return false; }
|
||||
})() }));
|
||||
})().catch(error => {
|
||||
const reason = error.message === 'Inherited pipes unexpectedly closed before cancellation' ? 'early_pipes'
|
||||
: error.message === 'cancel did not settle exited' ? 'cancel_stalled'
|
||||
: error.message === 'capture_missing' ? 'capture_missing'
|
||||
: error.message === 'descendant_not_ready' ? 'descendant_not_ready' : 'unexpected';
|
||||
console.error(JSON.stringify({ stage, reason, directExitCode, markerValid, stdoutAck, stderrAck,
|
||||
descendantAlive, checkErrorCode, errorCode: typeof error.code === 'string' ? error.code : null }));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`;
|
||||
const result = Bun.spawnSync(['node', '-e', script], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
||||
const errorOutput = result.stderr.toString().trim();
|
||||
let diagnostic: object | null = null;
|
||||
try { if (errorOutput) diagnostic = JSON.parse(errorOutput); }
|
||||
catch { diagnostic = { stage: 'unframed', stderrBytes: Buffer.byteLength(errorOutput) }; }
|
||||
expect({ exitCode: result.exitCode, diagnostic }).toEqual({ exitCode: 0, diagnostic: null });
|
||||
expect(JSON.parse(result.stdout.toString())).toEqual({ code: 0, directExitCode: 0, reads: [true, true], descendantAlive: true });
|
||||
} finally {
|
||||
if (fs.existsSync(pidPath)) {
|
||||
const pidText = fs.readFileSync(pidPath, 'utf8');
|
||||
if (/^[1-9]\d*$/.test(pidText)) {
|
||||
try { process.kill(Number(pidText)); } catch (error: any) { if (error.code !== 'ESRCH') throw error; }
|
||||
}
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Bun.serve creates an HTTP server that responds', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { chromium, errors, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { CookieImportError } from '../src/cookie-import-browser';
|
||||
import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication } from '../src/cookie-auth-verification';
|
||||
|
||||
const identity = 'Synthetic Account 472';
|
||||
const options = { identitySelector: '.identity', expectedIdentity: identity, timeoutMs: 400 };
|
||||
const unitOrigin = 'https://fixture.test';
|
||||
const credentialUrl = new URL(unitOrigin);
|
||||
credentialUrl.username = 'fixture-user';
|
||||
credentialUrl.password = 'fixture';
|
||||
credentialUrl.searchParams.set('token', 'synthetic-token');
|
||||
|
||||
function mockPage() {
|
||||
const evaluateAll = mock(async () => 'verified');
|
||||
const request = { url: () => `${unitOrigin}/protected`, redirectedFrom: () => null };
|
||||
const events = new EventEmitter();
|
||||
const frame = {};
|
||||
const storage = { engine: 'chromium', now: 100, deadline: 0, origin: unitOrigin, url: request.url(),
|
||||
localClear: mock(() => {}), sessionClear: mock(() => {}), nativeNow: mock(() => storage.now) };
|
||||
const cdpEvents = new EventEmitter();
|
||||
const cdp = {
|
||||
on: cdpEvents.on.bind(cdpEvents),
|
||||
off: cdpEvents.off.bind(cdpEvents),
|
||||
detach: mock(async () => {}),
|
||||
send: mock(async (method: string, params?: any): Promise<any> => {
|
||||
if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'fixture-frame' } } };
|
||||
if (method === 'Page.createIsolatedWorld') {
|
||||
cdpEvents.emit('Runtime.executionContextCreated', { context: { name: params.worldName, id: 7,
|
||||
uniqueId: 'fixture-unique-world', auxData: { frameId: 'fixture-frame', isDefault: false } } });
|
||||
return { executionContextId: 7 };
|
||||
}
|
||||
if (method === 'Runtime.evaluate') return { result: { value: storage.nativeNow() } };
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
const arg = params.arguments[0].value;
|
||||
storage.deadline = arg.deadline;
|
||||
const value = runInNewContext(`(${params.functionDeclaration})(arg)`, { arg,
|
||||
performance: { now: storage.nativeNow }, Date: { now: () => 0 },
|
||||
location: { origin: storage.origin, href: storage.url },
|
||||
localStorage: { clear: storage.localClear }, sessionStorage: { clear: storage.sessionClear },
|
||||
});
|
||||
return { result: { value } };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
const context = {
|
||||
browser: () => ({ browserType: () => ({ name: () => storage.engine }) }),
|
||||
newCDPSession: mock(async () => cdp),
|
||||
};
|
||||
const page = {
|
||||
isClosed: mock(() => false),
|
||||
url: mock(() => `${unitOrigin}/protected`),
|
||||
reload: mock(async () => ({ status: () => 200, url: request.url, request: () => request })),
|
||||
locator: mock(() => ({ filter: () => ({ evaluateAll }) })),
|
||||
evaluate: mock(async () => 'cleared'),
|
||||
context: () => context,
|
||||
mainFrame: () => frame,
|
||||
on: events.on.bind(events),
|
||||
off: events.off.bind(events),
|
||||
};
|
||||
return { page: page as unknown as Page, calls: page, evaluateAll, storage, cdp, context, events, frame, cdpEvents };
|
||||
}
|
||||
|
||||
describe('cookie auth configuration and bounded operations', () => {
|
||||
for (const [index, invalid] of [undefined, {}, { identitySelector: '.identity' }, { expectedIdentity: identity },
|
||||
{ identitySelector: ' ', expectedIdentity: identity }, { identitySelector: '.identity', expectedIdentity: '\n\t' },
|
||||
{ identitySelector: 7, expectedIdentity: identity }, { identitySelector: '.identity', expectedIdentity: [] }].entries()) {
|
||||
test(`rejects incomplete configuration case ${index + 1}`, () => {
|
||||
try {
|
||||
validateCookieAuthOptions(invalid as any);
|
||||
throw new Error('Expected configuration rejection');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('verification_not_configured');
|
||||
expect(String(error)).not.toContain(identity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const timeoutMs of [0, -1, NaN, Infinity, '200']) {
|
||||
test(`rejects invalid timeout ${String(timeoutMs)}`, async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(() => validateCookieAuthOptions({ ...options, timeoutMs } as any)).toThrow(CookieImportError);
|
||||
expect(await verifyCookieAuthentication(page, { ...options, timeoutMs } as any, unitOrigin))
|
||||
.toEqual({ verified: false, reason: 'invalid_configuration' });
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test('requires explicit configuration without reloading or touching storage', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(await verifyCookieAuthentication(page, {}, unitOrigin)).toEqual({ verified: false, reason: 'not_configured' });
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.locator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caps an oversized requested timeout at fifteen seconds', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect((await verifyCookieAuthentication(page, { ...options, timeoutMs: 60_000 }, unitOrigin)).verified).toBe(true);
|
||||
expect(calls.reload.mock.calls[0][0].timeout).toBeGreaterThan(0);
|
||||
expect(calls.reload.mock.calls[0][0].timeout).toBeLessThanOrEqual(15_000);
|
||||
});
|
||||
|
||||
test('does not run assertions after a timed-out reload eventually resolves', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
let release!: (response: any) => void;
|
||||
calls.reload.mockImplementation(() => new Promise(resolve => { release = resolve; }));
|
||||
const result = await verifyCookieAuthentication(page, { ...options, timeoutMs: 30 }, unitOrigin);
|
||||
expect(result).toEqual({ verified: false, reason: 'timeout' });
|
||||
release({ status: () => 200 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(evaluateAll).not.toHaveBeenCalled();
|
||||
expect(calls.locator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const stage of ['reload', 'selector']) {
|
||||
test(`classifies Playwright's ${stage} timeout before the outer timer expires`, async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const fail = async () => { throw new errors.TimeoutError(`${credentialUrl.href} ${identity}`); };
|
||||
if (stage === 'reload') calls.reload.mockImplementation(fail);
|
||||
else evaluateAll.mockImplementation(fail);
|
||||
const clock = spyOn(performance, 'now').mockReturnValue(0);
|
||||
try {
|
||||
const result = await verifyCookieAuthentication(page, options, unitOrigin);
|
||||
expect(result).toEqual({ verified: false, reason: 'timeout', ...(stage === 'selector' ? { status: 200 } : {}) });
|
||||
expect(JSON.stringify(result)).not.toContain(identity);
|
||||
expect(JSON.stringify(result)).not.toContain(credentialUrl.href);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('does not classify generic error text or a copied name as a Playwright timeout', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
calls.reload.mockImplementation(async () => { throw Object.assign(new Error('synthetic timeout exceeded'), { name: 'TimeoutError' }); });
|
||||
expect(await verifyCookieAuthentication(page, options, unitOrigin)).toEqual({ verified: false, reason: 'verification_failed' });
|
||||
});
|
||||
|
||||
test('shares one deadline between reload and the identity assertion', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const response = await calls.reload();
|
||||
let now = 0;
|
||||
let expire!: () => void;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => now);
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void, timeout: number) => {
|
||||
expect(timeout).toBe(100);
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
try {
|
||||
calls.reload.mockImplementation(async () => {
|
||||
now = 70;
|
||||
return response;
|
||||
});
|
||||
evaluateAll.mockImplementation(() => new Promise(() => {}));
|
||||
const result = verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, unitOrigin);
|
||||
await Promise.resolve();
|
||||
expect(evaluateAll).toHaveBeenCalledTimes(1);
|
||||
now = 100;
|
||||
expire();
|
||||
expect(await result).toEqual({ verified: false, reason: 'timeout', status: 200 });
|
||||
expect(timer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns only safe reasons for secret-bearing reload and selector failures', async () => {
|
||||
for (const stage of ['reload', 'selector']) {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const fail = async () => { throw new Error(`${credentialUrl.href} ${identity}`); };
|
||||
if (stage === 'reload') calls.reload.mockImplementation(fail);
|
||||
else evaluateAll.mockImplementation(fail);
|
||||
const result = await verifyCookieAuthentication(page, options, unitOrigin);
|
||||
expect(result.reason).toBe('verification_failed');
|
||||
expect(JSON.stringify(result)).not.toMatch(/synthetic-token|fixture|Synthetic Account/);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires a successful reload response even with a positive assertion', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
calls.reload.mockImplementation(async () => null as any);
|
||||
expect(await verifyCookieAuthentication(page, options, unitOrigin)).toEqual({ verified: false, reason: 'no_response' });
|
||||
expect(evaluateAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects a closed or changed target before reload', async () => {
|
||||
for (const state of ['closed', 'changed']) {
|
||||
const { page, calls } = mockPage();
|
||||
if (state === 'closed') calls.isClosed.mockReturnValue(true);
|
||||
else calls.url.mockReturnValue('https://other.test/protected');
|
||||
expect((await verifyCookieAuthentication(page, options, unitOrigin)).reason).toBe(`target_${state}`);
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
for (const expectedOrigin of ['about:blank', 'file:///tmp/fixture', 'data:text/html,fixture', 'invalid',
|
||||
'https://fixture.test/path', credentialUrl.href, 'https://fixture.test/?token=synthetic-token']) {
|
||||
test(`rejects a non-origin target ${expectedOrigin.split(':')[0]}`, async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(await verifyCookieAuthentication(page, options, expectedOrigin)).toEqual({ verified: false, reason: 'invalid_target' });
|
||||
expect(await clearCookieTargetStorage(page, expectedOrigin).then(() => null, error => error)).toMatchObject({ code: 'invalid_target' });
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test('storage reset sanitizes even a typed exception with a secret-bearing message', async () => {
|
||||
const { page, cdp } = mockPage();
|
||||
cdp.send.mockImplementation(async () => {
|
||||
throw new CookieImportError(`synthetic-token ${identity}`, 'target_changed');
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, unitOrigin);
|
||||
throw new Error('Expected reset failure');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('storage_reset_failed');
|
||||
expect(String(error)).not.toMatch(/synthetic-token|Synthetic Account/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a reset dispatched after its timeout does no destructive work', async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
let runLate!: () => void;
|
||||
let expire!: () => void;
|
||||
const dispatched = Promise.withResolvers<void>();
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method !== 'Runtime.callFunctionOn') return send(method, params);
|
||||
return new Promise(resolve => {
|
||||
runLate = () => {
|
||||
storage.now = params.arguments[0].value.deadline;
|
||||
resolve(send(method, params));
|
||||
};
|
||||
dispatched.resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await dispatched.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
runLate();
|
||||
await Promise.resolve();
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not clear session storage if the local-storage operation consumed the deadline', async () => {
|
||||
const { page, storage } = mockPage();
|
||||
storage.nativeNow.mockImplementation(() => storage.localClear.mock.calls.length ? storage.deadline : storage.now);
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const engine of ['firefox', 'webkit']) {
|
||||
test(`rejects reset support before protocol work on ${engine} without disabling authentication checks`, async () => {
|
||||
const { page, storage, context, calls } = mockPage();
|
||||
storage.engine = engine;
|
||||
expect(() => validateCookieStorageSupport(page)).toThrow(CookieImportError);
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'storage_reset_unsupported' });
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect((await verifyCookieAuthentication(page, options, unitOrigin)).verified).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('Chromium support preflight is side-effect free', () => {
|
||||
const { page, context, calls, cdp } = mockPage();
|
||||
validateCookieStorageSupport(page);
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
expect(cdp.send).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const phase of ['attachment', 'Page.getFrameTree', 'Runtime.enable', 'Page.createIsolatedWorld', 'Runtime.evaluate']) {
|
||||
test(`does not dispatch destructive work when timeout wins during ${phase}`, async () => {
|
||||
const { page, cdp, context, storage } = mockPage();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
let expire!: () => void;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
if (phase === 'attachment') context.newCDPSession.mockImplementation(async () => {
|
||||
reached.resolve();
|
||||
await release.promise;
|
||||
return cdp;
|
||||
});
|
||||
else cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === phase) { reached.resolve(); await release.promise; }
|
||||
return send(method, params);
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await reached.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
release.resolve();
|
||||
for (let tick = 0; tick < 10; tick++) await Promise.resolve();
|
||||
expect(cdp.send.mock.calls.some(([method]) => method === 'Runtime.callFunctionOn')).toBe(false);
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
expect(cdp.detach).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release.resolve();
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('calibrates a conservative isolated deadline using host time after the clock response', async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
let now = 0;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => now);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
const result = await send(method, params);
|
||||
if (method === 'Runtime.evaluate') { now = 4_000; storage.now = 4_100; }
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, unitOrigin);
|
||||
const sample = cdp.send.mock.calls.find(([method]) => method === 'Runtime.evaluate')![1];
|
||||
const mutation = cdp.send.mock.calls.find(([method]) => method === 'Runtime.callFunctionOn')![1];
|
||||
expect(mutation.arguments[0].value.deadline).toBe(11_100);
|
||||
expect(sample.uniqueContextId).toBe('fixture-unique-world');
|
||||
expect(mutation.uniqueContextId).toBe(sample.uniqueContextId);
|
||||
expect(mutation.executionContextId).toBeUndefined();
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not let pending CDP detach prolong the reported timeout', async () => {
|
||||
const { page, cdp, storage, events, cdpEvents } = mockPage();
|
||||
const detaching = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
let expire!: () => void;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
cdp.detach.mockImplementation(async () => { detaching.resolve(); await release.promise; });
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await detaching.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).toHaveBeenCalledTimes(1);
|
||||
expect(events.listenerCount('framenavigated')).toBe(0);
|
||||
expect(cdpEvents.listenerCount('Runtime.executionContextCreated')).toBe(0);
|
||||
} finally {
|
||||
release.resolve();
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('aborts same-URL target navigation before destructive dispatch', async () => {
|
||||
const { page, cdp, storage, events, frame } = mockPage();
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
events.emit('framenavigated', frame);
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(cdp.send.mock.calls.some(([method]) => method === 'Runtime.callFunctionOn')).toBe(false);
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const event of ['framenavigated', 'close']) {
|
||||
test(`settles ${event} cancellation without waiting for a stalled CDP operation`, async () => {
|
||||
const { page, cdp, events, frame, storage } = mockPage();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === 'Runtime.callFunctionOn') { reached.resolve(); return new Promise(() => {}); }
|
||||
return send(method, params);
|
||||
});
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await reached.promise;
|
||||
events.emit(event, frame);
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
expect(cdp.detach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const changed of ['origin', 'url']) {
|
||||
test(`checks the captured ${changed} inside the isolated destructive operation`, async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === 'Runtime.callFunctionOn') storage[changed] = changed === 'origin' ? 'https://other.test' : `${unitOrigin}/other-path`;
|
||||
return send(method, params);
|
||||
});
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('cookie authentication and storage with isolated Chromium profiles', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let otherServer: ReturnType<typeof Bun.serve>;
|
||||
let origin: string;
|
||||
let otherOrigin: string;
|
||||
let releaseSlowResponse: (() => void) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const html = (body: string, status = 200) => new Response(body, { status, headers: { 'Content-Type': 'text/html' } });
|
||||
const positive = `<div class="identity">${identity}</div>`;
|
||||
otherServer = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
if (new URL(request.url).pathname === '/back') return Response.redirect(`${origin}/identity`, 302);
|
||||
return html(positive);
|
||||
} });
|
||||
otherOrigin = `http://127.0.0.1:${otherServer.port}`;
|
||||
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/slow') return new Promise<Response>(resolve => {
|
||||
releaseSlowResponse = () => resolve(html(positive));
|
||||
});
|
||||
if (url.pathname === '/protected') {
|
||||
if (!request.headers.get('cookie')?.includes('fixture_session=approved')) return Response.redirect(`${origin}/login`, 302);
|
||||
return html(positive);
|
||||
}
|
||||
if (url.pathname === '/public-login') return html('<form><input name="email"><button>Sign in</button></form>');
|
||||
if (url.pathname === '/wrong') return html(`<div class="identity">${identity} extra account</div>`);
|
||||
if (url.pathname === '/normalized') return html('<div class="identity">\n Synthetic Account\n 472 </div>');
|
||||
if (url.pathname === '/hidden') return html(`<div class="identity" hidden>${identity}</div>`);
|
||||
if (url.pathname === '/duplicate') return html(positive + positive);
|
||||
if (url.pathname === '/one-visible') return html(positive + `<div class="identity" hidden>${identity}</div>`);
|
||||
if (url.pathname === '/hidden-child') return html(`<div class="identity">${identity}<span hidden>not visible</span></div>`);
|
||||
if (url.pathname === '/delayed') return html(`<script>setTimeout(() => document.body.innerHTML = ${JSON.stringify(positive)}, 100)</script>`);
|
||||
if (url.pathname === '/login' && url.searchParams.has('continue')) return Response.redirect(`${origin}/identity`, 302);
|
||||
if (url.pathname === '/login-hop') return Response.redirect(`${origin}/login?continue=1`, 302);
|
||||
if (url.pathname === '/redirect-cross') return Response.redirect(`${otherOrigin}/identity`, 302);
|
||||
if (url.pathname === '/roundtrip') return Response.redirect(`${otherOrigin}/back`, 302);
|
||||
if (url.pathname.startsWith('/status/')) return html(positive, Number(url.pathname.split('/')[2]));
|
||||
return html(positive);
|
||||
} });
|
||||
origin = `http://127.0.0.1:${server.port}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
server?.stop(true);
|
||||
otherServer?.stop(true);
|
||||
});
|
||||
|
||||
test('verifies a reloaded protected page only with an exact visible identity and synthetic session', async () => {
|
||||
await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]);
|
||||
await page.goto(`${origin}/protected`);
|
||||
expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: true, reason: 'verified', status: 200 });
|
||||
});
|
||||
|
||||
for (const path of ['/normalized', '/one-visible', '/hidden-child', '/delayed']) {
|
||||
test(`accepts one normalized visible identity at ${path}`, async () => {
|
||||
await page.goto(`${origin}${path}`);
|
||||
const result = await verifyCookieAuthentication(page, { ...options, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin);
|
||||
expect(result).toEqual({ verified: true, reason: 'verified', status: 200 });
|
||||
expect(JSON.stringify(result)).not.toContain(identity);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [path, reason] of [
|
||||
['/wrong', 'identity_mismatch'], ['/hidden', 'identity_missing'], ['/duplicate', 'identity_ambiguous'],
|
||||
['/public-login', 'identity_missing'], ['/login', 'login_redirect'], ['/sign-in', 'login_redirect'],
|
||||
['/account/LOGIN', 'login_redirect'], ['/login-hop', 'login_redirect'],
|
||||
]) {
|
||||
test(`rejects ${path} with a safe ${reason} result`, async () => {
|
||||
await page.goto(`${origin}${path === '/login-hop' ? '/identity' : path}`);
|
||||
if (path === '/login-hop') await page.evaluate(() => history.replaceState({}, '', '/login-hop'));
|
||||
const result = await verifyCookieAuthentication(page, options, origin);
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.reason).toBe(reason);
|
||||
expect(JSON.stringify(result)).not.toMatch(/Synthetic Account|127\.0\.0\.1/);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an unauthenticated 200 login redirect even when login markup contains the expected identity', async () => {
|
||||
await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]);
|
||||
await page.goto(`${origin}/protected`);
|
||||
await context.clearCookies();
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('login_redirect');
|
||||
});
|
||||
|
||||
for (const status of [401, 403, 500, 503]) {
|
||||
test(`rejects HTTP ${status} even with a positive identity`, async () => {
|
||||
await page.goto(`${origin}/status/${status}`);
|
||||
expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: false, reason: 'http_error', status });
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects a cross-origin redirect and a redirect that comes back to the original origin', async () => {
|
||||
for (const destination of ['/redirect-cross', '/roundtrip']) {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(path => history.replaceState({}, '', path), destination);
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects same-origin navigation during a delayed identity assertion', async () => {
|
||||
await page.goto(`${origin}/public-login`);
|
||||
const evaluateAll = page.locator.bind(page);
|
||||
const locator = spyOn(page, 'locator').mockImplementation((selector: string) => {
|
||||
const value = evaluateAll(selector);
|
||||
const filter = value.filter.bind(value);
|
||||
spyOn(value, 'filter').mockImplementation((filterOptions: any) => {
|
||||
const filtered = filter(filterOptions);
|
||||
const evaluate = filtered.evaluateAll.bind(filtered);
|
||||
spyOn(filtered, 'evaluateAll').mockImplementation(async (...args: any[]) => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
return evaluate(...args as [any, any]);
|
||||
});
|
||||
return filtered;
|
||||
});
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed');
|
||||
} finally {
|
||||
locator.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('times out an actual Chromium reload without asserting a login', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => history.replaceState({}, '', '/slow'));
|
||||
try {
|
||||
expect(await verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, origin))
|
||||
.toEqual({ verified: false, reason: 'timeout' });
|
||||
} finally {
|
||||
releaseSlowResponse?.();
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitizes invalid selector errors and handles a closed target', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
const result = await verifyCookieAuthentication(page, { ...options, identitySelector: '[synthetic-token' }, origin);
|
||||
expect(result.reason).toBe('verification_failed');
|
||||
expect(JSON.stringify(result)).not.toContain('synthetic-token');
|
||||
await page.close();
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_closed');
|
||||
expect(await clearCookieTargetStorage(page, origin).then(() => null, error => error)).toMatchObject({ code: 'target_closed' });
|
||||
});
|
||||
|
||||
test('preserves all storage during an explicit authentication check', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).verified).toBe(true);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')]))
|
||||
.toEqual(['original', 'original']);
|
||||
});
|
||||
|
||||
test('clears only exact-origin local storage and target-tab session storage', async () => {
|
||||
const sibling = await context.newPage();
|
||||
const other = await context.newPage();
|
||||
const otherHost = await context.newPage();
|
||||
const independentProfile = await browser.newContext();
|
||||
try {
|
||||
const independent = await independentProfile.newPage();
|
||||
const pages = [page, sibling, other, otherHost, independent];
|
||||
await Promise.all(pages.map((tab, index) => tab.goto(index === 2 ? `${otherOrigin}/identity`
|
||||
: index === 3 ? `http://localhost:${server.port}/identity` : `${origin}/identity`)));
|
||||
for (const tab of pages) await tab.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
const storage = await Promise.all(pages.map(tab => tab.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])));
|
||||
expect(storage).toEqual([[null, null], [null, 'original'], ['original', 'original'], ['original', 'original'], ['original', 'original']]);
|
||||
} finally {
|
||||
await independentProfile.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a different scheme, host, or port without clearing storage', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => localStorage.setItem('local', 'original'));
|
||||
for (const expectedOrigin of [origin.replace('http:', 'https:'), otherOrigin, `http://localhost:${server.port}`]) {
|
||||
expect(await clearCookieTargetStorage(page, expectedOrigin).then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(await page.evaluate(() => localStorage.getItem('local'))).toBe('original');
|
||||
}
|
||||
});
|
||||
|
||||
test('checks target origin and URL inside the storage operation after dispatch races', async () => {
|
||||
for (const destination of [`${otherOrigin}/identity`, `${origin}/other-path`]) {
|
||||
const target = await context.newPage();
|
||||
await target.goto(`${origin}/identity`);
|
||||
const other = await context.newPage();
|
||||
await other.goto(destination);
|
||||
await other.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
let navigation: Promise<void> | undefined;
|
||||
const raced = spyOn(context, 'newCDPSession').mockImplementation(async attachedTarget => {
|
||||
const session = await connect(attachedTarget);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
navigation = (async () => {
|
||||
await target.goto(destination);
|
||||
await target.evaluate(() => sessionStorage.setItem('session', 'original'));
|
||||
})();
|
||||
await navigation;
|
||||
}
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
const error = await clearCookieTargetStorage(target, origin).then(() => null, error => error);
|
||||
expect(error).toMatchObject({ code: 'target_changed' });
|
||||
expect(navigation).toBeDefined();
|
||||
await navigation;
|
||||
} finally {
|
||||
raced.mockRestore();
|
||||
}
|
||||
expect(await target.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
await target.close();
|
||||
await other.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('reports a partial reset safely when session storage clearing fails', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Object.defineProperty(sessionStorage, 'clear', { value() { throw new Error('synthetic-token'); } });
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
const failing = spyOn(context, 'newCDPSession').mockImplementation(async target => {
|
||||
const session = await connect(target);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') await send('Runtime.evaluate', {
|
||||
uniqueContextId: params.uniqueContextId,
|
||||
expression: "Object.defineProperty(sessionStorage, 'clear', { value() { throw new Error('synthetic-token'); } })",
|
||||
returnByValue: true, silent: true,
|
||||
});
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
throw new Error('Expected reset failure');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('storage_reset_failed');
|
||||
expect(String(error)).not.toContain('synthetic-token');
|
||||
} finally {
|
||||
failing.mockRestore();
|
||||
}
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual([null, 'original']);
|
||||
});
|
||||
|
||||
test('uses a native isolated clock despite main-world Date and performance tampering', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Date.now = () => 0;
|
||||
Object.defineProperty(performance, 'now', { value: () => 0 });
|
||||
Object.defineProperty(performance, 'timeOrigin', { value: 0 });
|
||||
});
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
expect(await page.evaluate(() => [Date.now(), performance.now(), performance.timeOrigin])).toEqual([0, 0, 0]);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual([null, null]);
|
||||
});
|
||||
|
||||
test('a real late isolated dispatch cannot clear storage after the host timeout even with forged page clocks', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Date.now = () => 0;
|
||||
Object.defineProperty(performance, 'now', { value: () => 0 });
|
||||
Object.defineProperty(performance, 'timeOrigin', { value: 0 });
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
const observer = await connect(page);
|
||||
await observer.send('Runtime.enable');
|
||||
const tree = await observer.send('Page.getFrameTree');
|
||||
await observer.send('Page.createIsolatedWorld', { frameId: tree.frameTree.frame.id, worldName: 'gstack-cookie-storage-reset', grantUniveralAccess: false });
|
||||
const dispatched = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<any>();
|
||||
let delayedParameters: any;
|
||||
const delaying = spyOn(context, 'newCDPSession').mockImplementation(async target => {
|
||||
const session = await connect(target);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
delayedParameters = params;
|
||||
dispatched.resolve();
|
||||
return release.promise;
|
||||
}
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, origin);
|
||||
await dispatched.promise;
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
const late = await observer.send('Runtime.callFunctionOn', delayedParameters);
|
||||
expect(late.result.value).toBe('storage_reset_timeout');
|
||||
expect(await page.evaluate(() => [Date.now(), performance.now(), performance.timeOrigin])).toEqual([0, 0, 0]);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
release.resolve(late);
|
||||
} finally {
|
||||
release.resolve({ result: { value: 'storage_reset_timeout' } });
|
||||
delaying.mockRestore();
|
||||
await observer.detach();
|
||||
}
|
||||
}, 25_000);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { CookieImportError, importCookies } from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let homedir: ReturnType<typeof spyOn>;
|
||||
let platform: PropertyDescriptor;
|
||||
let spawn: typeof Bun.spawn;
|
||||
|
||||
function fixture(encrypted: Buffer, hostPlatform: 'darwin' | 'linux' | 'win32' = 'darwin') {
|
||||
const folder = hostPlatform === 'darwin' ? 'Library/Application Support/Dia/User Data/Default'
|
||||
: hostPlatform === 'linux' ? '.config/chromium/Default' : 'AppData/Local/Chromium/User Data/Default';
|
||||
const dir = path.join(home, folder);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run("INSERT INTO cookies VALUES ('.fixture.test', 'session', '', ?, '/', 0, 1, 1, 0, 1)", [encrypted]);
|
||||
db.close();
|
||||
}
|
||||
|
||||
function cbcCookie(password: string, prefix = 'v10') {
|
||||
const key = crypto.pbkdf2Sync(password, 'saltysalt', prefix === 'v11' ? 1 : 1003, 16, 'sha1');
|
||||
const cipher = crypto.createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20));
|
||||
return Buffer.concat([Buffer.from(prefix), cipher.update(Buffer.concat([Buffer.alloc(32), Buffer.from('fixture-value')])), cipher.final()]);
|
||||
}
|
||||
|
||||
function pipe(content?: string): { stream: ReadableStream<Uint8Array>; close(): void; cancelled(): boolean } {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
if (content) controller.enqueue(Buffer.from(content));
|
||||
},
|
||||
cancel() { cancelled = true; },
|
||||
});
|
||||
return { stream, close: () => controller.close(), cancelled: () => cancelled };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-credential-deadline-'));
|
||||
homedir = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
spawn = Bun.spawn;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Bun.spawn = spawn;
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
homedir.mockRestore();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('credential subprocess whole-operation deadline', () => {
|
||||
for (const held of ['stdout', 'stderr'] as const) {
|
||||
test(`rejects an exited Keychain process when ${held} remains open`, async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const stdout = pipe(held === 'stdout' ? 'fixture-password' : undefined);
|
||||
const stderr = pipe(held === 'stderr' ? 'private-error-detail' : undefined);
|
||||
if (held !== 'stdout') stdout.close();
|
||||
if (held !== 'stderr') stderr.close();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 19 as any; });
|
||||
try {
|
||||
const result = importCookies('dia', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout', action: 'retry' });
|
||||
expect(killed).toBe(1);
|
||||
expect(held === 'stdout' ? stdout.cancelled() : stderr.cancelled()).toBe(true);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('bounds a process that never exits', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const stdout = pipe();
|
||||
const stderr = pipe();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: new Promise<number>(() => {}), stdout: stdout.stream, stderr: stderr.stream, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 20 as any; });
|
||||
try {
|
||||
const result = importCookies('dia', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' });
|
||||
expect(killed).toBe(1);
|
||||
expect(stdout.cancelled()).toBe(true);
|
||||
expect(stderr.cancelled()).toBe(true);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('caps credential output and does not expose it in errors', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const privateValue = 'private-credential-output';
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([privateValue.repeat(4_000)]).stream(),
|
||||
stderr: new Blob([]).stream(), kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let error: any;
|
||||
try { await importCookies('dia', ['fixture.test']); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_error');
|
||||
expect(error.message).not.toContain(privateValue);
|
||||
expect(killed).toBe(1);
|
||||
});
|
||||
|
||||
test('preserves Keychain success and denied/nonexistent policy', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(cbcCookie('fixture-password'));
|
||||
for (const [code, stderr, expected] of [[0, '', 'fixture-value'], [1, 'user canceled private-detail', 'keychain_denied'], [1, 'could not be found private-detail', 'keychain_not_found']] as const) {
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(code), stdout: new Blob([code ? '' : 'fixture-password\n']).stream(),
|
||||
stderr: new Blob([stderr]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
if (!code) expect((await importCookies('dia', ['fixture.test'])).cookies[0].value).toBe(expected);
|
||||
else await expect(importCookies('dia', ['fixture.test'])).rejects.toMatchObject({ code: expected });
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves Linux secret lookup through concurrent pipe draining', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
fixture(cbcCookie('test-linux-secret', 'v11'), 'linux');
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob(['test-linux-secret\n']).stream(),
|
||||
stderr: new Blob([]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
expect((await importCookies('chromium', ['fixture.test'])).cookies[0].value).toBe('fixture-value');
|
||||
});
|
||||
|
||||
test('bounds a Linux secret lookup with an exited child and inherited pipe', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
fixture(cbcCookie('fixture-other-password', 'v11'), 'linux');
|
||||
const stdout = pipe();
|
||||
const stderr = pipe();
|
||||
stderr.close();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream,
|
||||
kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 22 as any; });
|
||||
try {
|
||||
const result = importCookies('chromium', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
expect(await result).toMatchObject({ count: 0, failed: 1 });
|
||||
expect(killed).toBe(1);
|
||||
expect(stdout.cancelled()).toBe(true);
|
||||
} finally { timer.mockRestore(); }
|
||||
});
|
||||
|
||||
test('bounds DPAPI stdout and preserves successful extraction without exposing input', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||
const key = Buffer.alloc(32, 0x42);
|
||||
const nonce = Buffer.alloc(12, 0x22);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
||||
const encrypted = Buffer.concat([Buffer.from('v10'), nonce, cipher.update('fixture-value'), cipher.final(), cipher.getAuthTag()]);
|
||||
fixture(encrypted, 'win32');
|
||||
fs.writeFileSync(path.join(home, 'AppData/Local/Chromium/User Data/Local State'), JSON.stringify({ os_crypt: { encrypted_key: Buffer.from('DPAPIsynthetic').toString('base64') } }));
|
||||
const stdout = pipe(key.toString('base64'));
|
||||
const stderr = pipe();
|
||||
let sent = '';
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream,
|
||||
stdin: { write(value: string) { sent = value; }, end() {} }, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 21 as any; });
|
||||
try {
|
||||
const result = importCookies('chromium', ['fixture.test']);
|
||||
expect(sent).toBe(Buffer.from('synthetic').toString('base64'));
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' });
|
||||
expect(killed).toBe(1);
|
||||
} finally { timer.mockRestore(); }
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([key.toString('base64')]).stream(),
|
||||
stderr: new Blob([]).stream(), stdin: { write(value: string) { sent = value; }, end() {} }, kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
expect((await importCookies('chromium', ['fixture.test'])).cookies[0].value).toBe('fixture-value');
|
||||
});
|
||||
|
||||
test('Node polyfill replay streams work with the bundled importer and a synthetic credential child', () => {
|
||||
const bundle = path.join(home, 'importer.mjs');
|
||||
const build = spawnSync(process.execPath, ['build', path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'), '--target=node', '--outfile', bundle], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(build.status).toBe(0);
|
||||
const node = Bun.which('node')!;
|
||||
const polyfill = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
||||
const nodeFixture = path.join(home, 'node-fixture');
|
||||
fs.mkdirSync(nodeFixture);
|
||||
const dir = path.join(nodeFixture, 'Library/Application Support/Dia/User Data/Default');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(nodeFixture) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run("INSERT INTO cookies VALUES ('.fixture.test', 'session', '', ?, '/', 0, 1, 1, 0, 1)", [cbcCookie('fixture-node-password')]);
|
||||
db.close();
|
||||
const result = spawnSync(node, ['--input-type=module', '-e', `
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
require(process.argv[1]);
|
||||
const original = Bun.spawn;
|
||||
Bun.spawn = (_command, options) => original([process.execPath, '-e', 'console.log("fixture-node-password")'], options);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
const { importCookies } = await import(process.argv[2]);
|
||||
const result = await importCookies('dia', ['fixture.test']);
|
||||
console.log(JSON.stringify({ count: result.count, value: result.cookies[0]?.value }));
|
||||
`, polyfill, pathToFileURL(bundle).href], { encoding: 'utf8', timeout: 15_000,
|
||||
env: { ...process.env, HOME: nodeFixture, USERPROFILE: nodeFixture, NODE_NO_WARNINGS: '1' } });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.stderr).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ count: 1, value: 'fixture-value' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { openCookieDatabase } from '../src/cookie-database';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-db-')));
|
||||
const dbPath = path.join(root, 'Cookies');
|
||||
const adapterPath = path.join(root, 'cookie-database.mjs');
|
||||
const corruptPath = path.join(root, 'private-corrupt-fixture');
|
||||
const missingPath = path.join(root, 'private-missing-fixture');
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node.js is required for cookie database adapter tests');
|
||||
|
||||
beforeAll(() => {
|
||||
const database = new Database(dbPath);
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, encrypted_value BLOB, expires_utc INTEGER, has_expires INTEGER)');
|
||||
const insert = database.query('INSERT INTO cookies VALUES (?, ?, ?, ?, ?)');
|
||||
insert.run('.fixture.test', 'large-expiry', new Uint8Array([0, 127, 255]), 13300000000000001n, 1);
|
||||
insert.run('.fixture.test', 'session', new Uint8Array([]), 0, 0);
|
||||
insert.run('.fixture.test', 'expired', new Uint8Array([]), 500, 1);
|
||||
insert.run('.other.test', 'other-session', new Uint8Array([]), 0, 0);
|
||||
database.close();
|
||||
chmodSync(dbPath, 0o600);
|
||||
writeFileSync(corruptPath, 'private-fixture-not-a-database', { mode: 0o600 });
|
||||
const source = readFileSync(path.resolve(import.meta.dir, '../src/cookie-database.ts'), 'utf8');
|
||||
writeFileSync(adapterPath, new Bun.Transpiler({ loader: 'ts', target: 'node' }).transformSync(source), { mode: 0o600 });
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function runNode(source: string, flags: string[] = []) {
|
||||
const result = spawnSync(node!, ['--input-type=module', ...flags, '-e', source, pathToFileURL(adapterPath).href, dbPath, corruptPath, missingPath], {
|
||||
encoding: 'utf8', timeout: 10_000, env: {
|
||||
PATH: path.dirname(node!), HOME: root, USERPROFILE: root, TEMP: root, TMP: root,
|
||||
NODE_NO_WARNINGS: '1', ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
},
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
describe('cookie database runtime adapter', () => {
|
||||
test('reproduces the old Node null stub before cookie queries can run', () => {
|
||||
const result = runNode(`
|
||||
import { existsSync } from 'node:fs';
|
||||
const Database = null;
|
||||
let attemptedQuery = false;
|
||||
try {
|
||||
const database = new Database(process.argv[2], { readonly: true });
|
||||
attemptedQuery = true;
|
||||
database.query('SELECT host_key FROM cookies').all();
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({ fixtureExists: existsSync(process.argv[2]), error: error.name, attemptedQuery }));
|
||||
}
|
||||
`);
|
||||
expect(result).toEqual({ fixtureExists: true, error: 'TypeError', attemptedQuery: false });
|
||||
});
|
||||
|
||||
test('Bun returns JSON-safe domain counts and preserves precise expiry integers and blobs', () => {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
const domains = database.query('SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC').all(1000);
|
||||
expect(domains).toEqual([{ domain: '.fixture.test', count: 2 }, { domain: '.other.test', count: 1 }]);
|
||||
expect(JSON.parse(JSON.stringify(domains))).toEqual(domains);
|
||||
const rows = database.query('SELECT name, encrypted_value, expires_utc, has_expires FROM cookies WHERE host_key IN (?, ?) AND expires_utc = ?').all('fixture.test', '.fixture.test', 13300000000000001n) as any[];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].expires_utc).toBe(13300000000000001n);
|
||||
expect(rows[0].has_expires).toBe(1);
|
||||
expect(Array.from(rows[0].encrypted_value)).toEqual([0, 127, 255]);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Node uses built-in SQLite even with a Bun server polyfill and preserves the same values', () => {
|
||||
const result = runNode(`
|
||||
globalThis.Bun = { fixturePolyfill: true };
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const domains = database.query('SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC').all(1000);
|
||||
const rows = database.query('SELECT name, encrypted_value, expires_utc, has_expires FROM cookies WHERE host_key IN (?, ?) AND expires_utc = ?').all('fixture.test', '.fixture.test', 13300000000000001n);
|
||||
console.log(JSON.stringify({ domains, rowCount: rows.length, expiry: String(rows[0].expires_utc), expiryType: typeof rows[0].expires_utc,
|
||||
flag: rows[0].has_expires, blob: Array.from(rows[0].encrypted_value) }));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
expect(result).toEqual({ domains: [{ domain: '.fixture.test', count: 2 }, { domain: '.other.test', count: 1 }],
|
||||
rowCount: 1, expiry: '13300000000000001', expiryType: 'bigint', flag: 1, blob: [0, 127, 255] });
|
||||
});
|
||||
|
||||
for (const runtime of ['Bun', 'Node']) {
|
||||
test(`${runtime} converts only integers inside the safe Number range`, () => {
|
||||
const sql = 'SELECT 9007199254740991 AS safe_positive, -9007199254740991 AS safe_negative, 9007199254740992 AS unsafe_positive, -9007199254740992 AS unsafe_negative, 9223372036854775807 AS maximum, 1.5 AS fractional, NULL AS empty';
|
||||
let row: any;
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
row = database.query(sql).all()[0];
|
||||
row = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, { type: typeof value, value: String(value) }]));
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
row = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const row = database.query(${JSON.stringify(sql)}).all()[0];
|
||||
console.log(JSON.stringify(Object.fromEntries(Object.entries(row).map(([key, value]) => [key, { type: typeof value, value: String(value) }]))));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
}
|
||||
expect(row).toEqual({ safe_positive: { type: 'number', value: '9007199254740991' }, safe_negative: { type: 'number', value: '-9007199254740991' },
|
||||
unsafe_positive: { type: 'bigint', value: '9007199254740992' }, unsafe_negative: { type: 'bigint', value: '-9007199254740992' },
|
||||
maximum: { type: 'bigint', value: '9223372036854775807' }, fractional: { type: 'number', value: '1.5' }, empty: { type: 'object', value: 'null' } });
|
||||
});
|
||||
|
||||
test(`${runtime} refuses database writes and leaves the source bytes unchanged`, () => {
|
||||
const before = readFileSync(dbPath);
|
||||
const writes = ["INSERT INTO cookies VALUES ('private-insert', 'attempt', NULL, 0, 0)",
|
||||
"UPDATE cookies SET name = 'private-update'", 'DELETE FROM cookies', 'CREATE TABLE private_created_table (value TEXT)'];
|
||||
let results: any[];
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
results = writes.map(sql => {
|
||||
try { database.query(sql).all(); return { wrote: true }; }
|
||||
catch (error: any) { return { code: error.code, message: error.message }; }
|
||||
});
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
results = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const results = ${JSON.stringify(writes)}.map(sql => {
|
||||
try { database.query(sql).all(); return { wrote: true }; }
|
||||
catch (error) { return { code: error.code, message: error.message }; }
|
||||
});
|
||||
console.log(JSON.stringify(results));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
}
|
||||
expect(results).toHaveLength(4);
|
||||
for (const result of results) {
|
||||
expect(result.code).toBe('SQLITE_READONLY');
|
||||
expect(result.message).not.toContain('private-');
|
||||
expect(result.message).not.toContain('private_created_table');
|
||||
}
|
||||
expect(readFileSync(dbPath)).toEqual(before);
|
||||
});
|
||||
|
||||
test(`${runtime} keeps parameters bound instead of interpreting SQL-looking values`, () => {
|
||||
const value = ".fixture.test'; DROP TABLE cookies; --";
|
||||
let rows: unknown[];
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
rows = database.query('SELECT name FROM cookies WHERE host_key = ?').all(value);
|
||||
expect(database.query('SELECT COUNT(*) AS count FROM cookies').all()).toEqual([{ count: 4 }]);
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
const result = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const rows = database.query('SELECT name FROM cookies WHERE host_key = ?').all(${JSON.stringify(value)});
|
||||
console.log(JSON.stringify({ rows, counts: database.query('SELECT COUNT(*) AS count FROM cookies').all() }));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
rows = result.rows;
|
||||
expect(result.counts).toEqual([{ count: 4 }]);
|
||||
}
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${runtime} supports a mutable close method for copied-profile cleanup`, () => {
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
const close = database.close.bind(database);
|
||||
let cleanup = false;
|
||||
database.close = () => { close(); cleanup = true; };
|
||||
database.close();
|
||||
expect(cleanup).toBe(true);
|
||||
expect(() => database.query('SELECT 1').all()).toThrow();
|
||||
} else {
|
||||
expect(runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
const close = database.close.bind(database);
|
||||
let cleanup = false;
|
||||
database.close = () => { close(); cleanup = true; };
|
||||
database.close();
|
||||
let closed = false;
|
||||
try { database.query('SELECT 1').all(); } catch { closed = true; }
|
||||
console.log(JSON.stringify({ cleanup, closed }));
|
||||
`)).toEqual({ cleanup: true, closed: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime} sanitizes open, corrupt-database, query, and binding failures`, () => {
|
||||
const exercise = (open: typeof openCookieDatabase, missing: string, corrupt: string, valid: string) => {
|
||||
const results: { code: string; message: string }[] = [];
|
||||
for (const file of [missing, corrupt]) {
|
||||
let database: ReturnType<typeof openCookieDatabase> | undefined;
|
||||
try { database = open(file); database.query('SELECT * FROM cookies').all(); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
finally { database?.close(); }
|
||||
}
|
||||
const database = open(valid);
|
||||
try {
|
||||
try { database.query('SELECT private_query_sentinel FROM cookies').all(); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
try { database.query('SELECT ? AS value').all(Symbol('private-binding-sentinel')); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
} finally { database.close(); }
|
||||
return results;
|
||||
};
|
||||
const results = runtime === 'Bun' ? exercise(openCookieDatabase, missingPath, corruptPath, dbPath) : runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const exercise = ${exercise.toString()};
|
||||
console.log(JSON.stringify(exercise(openCookieDatabase, process.argv[4], process.argv[3], process.argv[2])));
|
||||
`);
|
||||
expect(results.map((result: any) => result.code)).toEqual(['SQLITE_CANTOPEN', 'SQLITE_CORRUPT', 'SQLITE_ERROR', 'SQLITE_ERROR']);
|
||||
expect(JSON.stringify(results)).not.toMatch(/private[-_]|Cookies|cookie-db-/);
|
||||
expect(existsSync(missingPath)).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['missing module', 'missing export']) {
|
||||
test(`Node import remains available without SQLite and cookie use fails safely (${failure})`, () => {
|
||||
const result = runNode(`
|
||||
import Module from 'node:module';
|
||||
const original = Module._load;
|
||||
let loads = 0;
|
||||
Module._load = function(name, ...args) {
|
||||
if (name === 'node:sqlite') {
|
||||
loads++;
|
||||
if (${JSON.stringify(failure)} === 'missing export') return {};
|
||||
throw new Error('private-module-error');
|
||||
}
|
||||
return original.call(this, name, ...args);
|
||||
};
|
||||
try {
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const loadsAtImport = loads;
|
||||
try { openCookieDatabase(process.argv[2]); }
|
||||
catch (error) { console.log(JSON.stringify({ loadsAtImport, loads, code: error.code, message: error.message })); }
|
||||
} finally { Module._load = original; }
|
||||
`);
|
||||
expect(result.loadsAtImport).toBe(0);
|
||||
expect(result.loads).toBe(1);
|
||||
expect(result.code).toBe('sqlite_unavailable');
|
||||
expect(result.message).toContain('Node.js 22.13 or newer');
|
||||
expect(result.message).toContain('Upgrade Node.js or enable SQLite');
|
||||
expect(result.message).not.toContain('private-module-error');
|
||||
expect(result.message).not.toContain(dbPath);
|
||||
});
|
||||
}
|
||||
|
||||
test('actual Node with SQLite disabled can import the module and gets version guidance only on cookie use', () => {
|
||||
const result = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
try { openCookieDatabase(process.argv[2]); }
|
||||
catch (error) { console.log(JSON.stringify({ imported: true, code: error.code, message: error.message })); }
|
||||
`, ['--no-experimental-sqlite']);
|
||||
expect(result.imported).toBe(true);
|
||||
expect(result.code).toBe('sqlite_unavailable');
|
||||
expect(result.message).toContain('Node.js 22.13 or newer');
|
||||
expect(result.message).toContain('Upgrade Node.js or enable SQLite');
|
||||
expect(result.message).not.toContain(dbPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterAll, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
import { createFixtureDeleteLease, deleteWithFixtureLease, FixtureDeleteError, type FixtureDeleteBackend } from './fixtures/native-cookie-delete-lease';
|
||||
import { nativeCookieEnvironment } from '../src/cookie-import-native-worker';
|
||||
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'delete-lease-'));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
const identity = { dev: 7n, ino: 9007199254740993n, mode: 0o100666n };
|
||||
|
||||
function model() {
|
||||
let time = 0;
|
||||
const calls: string[] = [];
|
||||
const backend: FixtureDeleteBackend = {
|
||||
open: () => { calls.push('open'); return 42; },
|
||||
identity: () => { calls.push('identity'); return { ...identity, attributes: 0x80, filesystem: 'NTFS' }; },
|
||||
dispose: () => { calls.push('dispose'); },
|
||||
close: () => { calls.push('close'); },
|
||||
absent: () => { calls.push('absent'); return true; },
|
||||
};
|
||||
const verify = () => { calls.push('verify'); };
|
||||
const clock = { now: () => time, wait: (ms: number) => { calls.push(`wait:${ms}`); time += ms; } };
|
||||
return { backend, calls, verify, clock, advance: (ms: number) => { time += ms; } };
|
||||
}
|
||||
|
||||
test('an admitted identity is deleted once, closed, and checked for absence in that order', () => {
|
||||
const f = model();
|
||||
expect(deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toEqual({ admissionProbes: 1, waitedMs: 0 });
|
||||
expect(f.calls).toEqual(['verify', 'open', 'identity', 'verify', 'dispose', 'close', 'absent']);
|
||||
});
|
||||
|
||||
test('sharing admission can settle inside the same deadline without retrying deletion', () => {
|
||||
const f = model(); let attempts = 0;
|
||||
f.backend.open = () => { f.calls.push('open'); if (++attempts < 3) throw new FixtureDeleteError('admission', 'owned', 32); return 42; };
|
||||
expect(deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toEqual({ admissionProbes: 3, waitedMs: 40 });
|
||||
expect(f.calls.filter(call => call === 'dispose')).toHaveLength(1);
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('persistent sharing keeps its first error and performs zero deletes', () => {
|
||||
const f = model(); const first = new FixtureDeleteError('admission', 'owned', 32); let attempts = 0;
|
||||
f.backend.open = () => { attempts++; throw attempts === 1 ? first : new FixtureDeleteError('admission', 'owned', 32); };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 40, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(first);
|
||||
expect(attempts).toBe(2);
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
expect(f.calls).not.toContain('close');
|
||||
});
|
||||
|
||||
test.each([2, 3, 5, 33, 50, 87])('admission error %s is never polled or deleted', code => {
|
||||
const f = model(); const original = new FixtureDeleteError('admission', 'owned', code);
|
||||
f.backend.open = () => { f.calls.push('open'); throw original; };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(original);
|
||||
expect(f.calls).toEqual(['verify', 'open']);
|
||||
});
|
||||
|
||||
test.each(['volume', 'inode', 'ReFS', 'readonly', 'directory', 'reparse', 'ancestor'])(
|
||||
'a rejected %s identity closes its handle without deleting', defect => {
|
||||
const f = model();
|
||||
f.backend.identity = () => ({ ...identity, dev: defect === 'volume' ? 8n : identity.dev,
|
||||
ino: defect === 'inode' ? 9007199254740992n : identity.ino, filesystem: defect === 'ReFS' ? 'ReFS' : 'NTFS',
|
||||
attributes: { readonly: 1, directory: 16, reparse: 1024 }[defect] ?? 0x80 });
|
||||
let checks = 0;
|
||||
const verify = () => { if (++checks === 2 && defect === 'ancestor') throw new Error('Owned ancestor changed'); };
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, verify, f.backend, f.clock)).toThrow();
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(1);
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
});
|
||||
|
||||
test.each(['before_open', 'after_verify', 'after_identity', 'after_revalidation'])(
|
||||
'expiration %s never grants late deletion', stage => {
|
||||
const f = model(); let checks = 0;
|
||||
if (stage === 'before_open') f.advance(100);
|
||||
const verify = () => { checks++; if (stage === 'after_verify' && checks === 1 || stage === 'after_revalidation' && checks === 2) f.advance(100); };
|
||||
const identify = f.backend.identity;
|
||||
f.backend.identity = (handle, file) => { const result = identify(handle, file); if (stage === 'after_identity') f.advance(100); return result; };
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, verify, f.backend, f.clock)).toThrow('deadline');
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(stage.startsWith('before') || stage === 'after_verify' ? 0 : 1);
|
||||
});
|
||||
|
||||
test('a disposition failure is not retried and survives close', () => {
|
||||
const f = model(); const original = new FixtureDeleteError('disposition', 'owned', 32);
|
||||
f.backend.dispose = () => { f.calls.push('dispose'); throw original; };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(original);
|
||||
expect(f.calls.filter(call => call === 'dispose')).toHaveLength(1);
|
||||
expect(f.calls.at(-1)).toBe('close');
|
||||
expect(f.calls.some(call => call.startsWith('wait'))).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['close', 'absence'])('%s failure never reports completed removal', stage => {
|
||||
const f = model();
|
||||
if (stage === 'close') f.backend.close = () => { throw new FixtureDeleteError('close', 'owned', 6); };
|
||||
else f.backend.absent = () => false;
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toThrow(stage);
|
||||
});
|
||||
|
||||
test('the actual Windows binding uses the checked handle, NTFS identity, and Ex flags without a fallback', () => {
|
||||
const source = readFileSync(path.join(import.meta.dir, 'fixtures/native-cookie-delete-lease.ts'), 'utf8');
|
||||
const start = source.indexOf('export function createFixtureDeleteLease(');
|
||||
expect(start).toBeGreaterThan(0);
|
||||
const body = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.slice(start).replace('export function', 'function'));
|
||||
const calls: string[] = [];
|
||||
const api = {
|
||||
CreateFileW(name: Buffer, access: number, share: number, security: unknown, creation: number, flags: number, template: number) {
|
||||
calls.push('open');
|
||||
expect(name.toString('utf16le')).toBe('namespaced-owned\0');
|
||||
expect([access, share, security, creation, flags, template]).toEqual([0x10080, 3, null, 3, 0x00200000, 0]);
|
||||
return 42;
|
||||
},
|
||||
GetLastError() { calls.push('last-error'); return 0; },
|
||||
GetFileInformationByHandle(handle: number, info: Buffer) {
|
||||
calls.push('identity'); expect(handle).toBe(42); expect(info.length).toBe(52);
|
||||
info.writeUInt32LE(0x80, 0); info.writeUInt32LE(Number(identity.dev), 28);
|
||||
info.writeUInt32LE(Number(identity.ino >> 32n), 44); info.writeUInt32LE(Number(identity.ino & 0xffffffffn), 48);
|
||||
return 1;
|
||||
},
|
||||
GetVolumeInformationByHandleW(handle: number, name: unknown, length: number, serial: unknown, component: unknown, flags: unknown, filesystem: Buffer, size: number) {
|
||||
calls.push('filesystem'); expect([handle, name, length, serial, component, flags, size]).toEqual([42, null, 0, null, null, null, 64]);
|
||||
expect(filesystem.length).toBe(128); filesystem.write('NTFS\0', 'utf16le'); return 1;
|
||||
},
|
||||
SetFileInformationByHandle(handle: number, kind: number, flags: Buffer, size: number) {
|
||||
calls.push('dispose'); expect([handle, kind, flags.length, flags.readUInt32LE(0), size]).toEqual([42, 21, 4, 3, 4]); return 1;
|
||||
},
|
||||
CloseHandle(handle: number) { calls.push('close'); expect(handle).toBe(42); return 1; },
|
||||
};
|
||||
const factory = new Function('process', 'dlopen', 'FFIType', 'ptr', 'unlinkSync', 'lstatSync', 'toNamespacedPath',
|
||||
'FixtureDeleteError', 'deleteWithFixtureLease', 'leaseClock', `${body}\nreturn createFixtureDeleteLease;`)(
|
||||
{ platform: 'win32' }, (name: string) => { expect(name).toBe('kernel32.dll'); return { symbols: api, close() { calls.push('unload'); } }; },
|
||||
{ ptr: 'ptr', u32: 'u32', u64: 'u64', i32: 'i32' }, (value: Buffer) => value,
|
||||
() => { throw new Error('Regular files must not use a path-based fallback'); },
|
||||
() => { calls.push('absence'); throw Object.assign(new Error('Absent'), { code: 'ENOENT' }); },
|
||||
(file: string) => { expect(file).toBe('owned'); return 'namespaced-owned'; },
|
||||
FixtureDeleteError, deleteWithFixtureLease, { now: () => 0, wait: () => { throw new Error('Unexpected wait'); } });
|
||||
const lease = factory(100);
|
||||
lease.unlink('owned', identity, () => { calls.push('verify'); });
|
||||
lease.close();
|
||||
expect(calls).toEqual(['verify', 'open', 'last-error', 'identity', 'filesystem', 'verify', 'dispose', 'close', 'absence', 'unload']);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('the native lease refuses a persistent no-delete-sharing holder', () => {
|
||||
const file = path.join(root, 'persistent'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(file + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0);
|
||||
const lease = createFixtureDeleteLease(performance.now() + 1_000);
|
||||
try {
|
||||
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
|
||||
expect(BigInt(handle)).not.toBe(0n);
|
||||
let failure: unknown;
|
||||
try { lease.unlink(file, expected, () => {}); } catch (error) { failure = error; }
|
||||
expect(failure).toBeInstanceOf(FixtureDeleteError);
|
||||
expect(failure).toMatchObject({ stage: 'admission', win32Error: 32, code: 'EBUSY' });
|
||||
expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
||||
expect(lstatSync(file, { bigint: true }).ino).toBe(expected.ino);
|
||||
} finally { lease.close(); if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) kernel.symbols.CloseHandle(handle); kernel.close(); }
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('a compatible reader retains its data while the admitted delete removes the namespace', () => {
|
||||
const directory = mkdtempSync(path.join(root, 'reader-'));
|
||||
const file = path.join(directory, 'data'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
ReadFile: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(file + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 7, null, 3, 0x80, 0);
|
||||
const lease = createFixtureDeleteLease(performance.now() + 2_000);
|
||||
try {
|
||||
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
|
||||
expect(BigInt(handle)).not.toBe(0n);
|
||||
let checks = 0;
|
||||
lease.unlink(file, expected, () => { if (++checks === 2) expect(() => renameSync(file, path.join(directory, 'moved'))).toThrow(); });
|
||||
expect(checks).toBe(2);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
rmdirSync(directory);
|
||||
const buffer = Buffer.alloc(32), bytes = Buffer.alloc(4);
|
||||
expect(kernel.symbols.ReadFile(handle, ptr(buffer), buffer.length, ptr(bytes), null)).toBe(1);
|
||||
expect(buffer.subarray(0, bytes.readUInt32LE()).toString()).toBe('fixture-only');
|
||||
} finally { lease.close(); if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) kernel.symbols.CloseHandle(handle); kernel.close(); }
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('an independently released holder admits one identity-bound deletion', async () => {
|
||||
const directory = mkdtempSync(path.join(root, 'released-'));
|
||||
const file = path.join(directory, 'held'), ready = path.join(directory, 'ready'), release = path.join(directory, 'release');
|
||||
writeFileSync(file, 'fixture-only'); const expected = lstatSync(file, { bigint: true });
|
||||
const script = `
|
||||
const { dlopen, FFIType, ptr } = await import('bun:ffi');
|
||||
const { existsSync, writeFileSync } = await import('node:fs');
|
||||
const api = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(${JSON.stringify(file)} + '\\0', 'utf16le');
|
||||
const handle = api.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0);
|
||||
if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new Error('Holder open failed');
|
||||
try {
|
||||
writeFileSync(${JSON.stringify(ready)}, 'ready');
|
||||
while (!existsSync(${JSON.stringify(release)})) await Bun.sleep(10);
|
||||
} finally { if (!api.symbols.CloseHandle(handle)) throw new Error('Holder close failed'); api.close(); }
|
||||
`;
|
||||
const child: ChildProcess = spawn(process.execPath, ['--no-env-file', '--no-install', '--config=NUL', '-e', script],
|
||||
{ env: nativeCookieEnvironment(process.env), stdio: 'ignore', windowsHide: true });
|
||||
let spawnError: Error | undefined;
|
||||
child.once('error', error => { spawnError = error; });
|
||||
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
const timer = setTimeout(() => child.kill(), 5_000);
|
||||
let lease: ReturnType<typeof createFixtureDeleteLease> | undefined;
|
||||
try {
|
||||
const deadline = performance.now() + 3_000;
|
||||
while (!existsSync(ready) && child.exitCode === null && !spawnError && performance.now() < deadline) await Bun.sleep(10);
|
||||
expect(spawnError).toBeUndefined();
|
||||
expect(existsSync(ready)).toBe(true);
|
||||
let blocked = 0;
|
||||
lease = createFixtureDeleteLease(performance.now() + 2_000, () => { blocked++; writeFileSync(release, 'release'); });
|
||||
lease.unlink(file, expected, () => {});
|
||||
expect(blocked).toBe(1);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
await closed;
|
||||
expect(child.exitCode).toBe(0);
|
||||
} finally { clearTimeout(timer); lease?.close(); child.kill(); await closed; }
|
||||
}, 10_000);
|
||||
|
||||
test.skipIf(process.platform !== 'win32').each(['identity', 'readonly', 'reparse'])(
|
||||
'the native lease refuses %s without removing the file or target', defect => {
|
||||
const directory = mkdtempSync(path.join(root, 'refused-'));
|
||||
const file = path.join(directory, 'data'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const destination = path.join(directory, 'destination');
|
||||
if (defect === 'readonly') chmodSync(file, 0o444);
|
||||
if (defect === 'reparse') {
|
||||
unlinkSync(file); mkdirSync(destination); writeFileSync(path.join(destination, 'preserved'), 'fixture-only');
|
||||
symlinkSync(destination, file, 'junction');
|
||||
}
|
||||
const lease = createFixtureDeleteLease(performance.now() + 2_000);
|
||||
try {
|
||||
expect(() => lease.unlink(file, defect === 'identity' ? { ...expected, ino: expected.ino + 1n } : expected, () => {})).toThrow();
|
||||
expect(existsSync(file)).toBe(true);
|
||||
if (defect === 'reparse') expect(readFileSync(path.join(destination, 'preserved'), 'utf8')).toBe('fixture-only');
|
||||
else expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
||||
} finally { lease.close(); if (defect === 'readonly') chmodSync(file, 0o666); }
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { release, tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { nativeBrowserPaths } from '../src/cookie-import-native';
|
||||
import { nativeCookieEnvironment, probeNativeCookieMember } from '../src/cookie-import-native-worker';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, NATIVE_BROWSER_VERSION_COMMAND, NATIVE_QUALIFICATION_DATA } from '../src/cookie-import-native-integrity';
|
||||
import { createNativeCookieJob, NativeCookieJobError, nativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from '../src/cookie-import-native-job';
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
console.error('Native cookie qualification requires Windows; no cases ran and no qualification was issued.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') {
|
||||
console.error('Native cookie qualification requires a disposable GitHub Actions runner and never uses an existing browser profile.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const output = mkdtempSync(path.join(process.argv[2] || tmpdir(), 'cookie-native-qualification-'));
|
||||
function incomplete(reason: string, diagnostic?: NativeCookieDiagnostic): never {
|
||||
const receipt = { status: 'incomplete', reason, ...(diagnostic ? { diagnostic } : {}), counts: { pass: 0, fail: 0, skip: 0 }, activation: 'No build was qualified; production extraction remains disabled.' };
|
||||
writeFileSync(path.join(output, 'qualification.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ ...receipt, artifactDirectory: output }));
|
||||
process.exit(2);
|
||||
}
|
||||
if (Bun.version !== '1.4.0') incomplete('bun_1_4_0_required');
|
||||
let emptyJob: NativeCookieJob | undefined;
|
||||
try {
|
||||
emptyJob = await createNativeCookieJob();
|
||||
if (emptyJob.activeProcesses() !== 0) throw new NativeCookieJobError('job_query');
|
||||
emptyJob.terminate();
|
||||
if (emptyJob.activeProcesses() !== 0) throw new NativeCookieJobError('job_query');
|
||||
emptyJob.close();
|
||||
const preflight = { status: 'passed', activeProcesses: 0 };
|
||||
writeFileSync(path.join(output, 'job-preflight.json'), JSON.stringify(preflight, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeJobPreflight: preflight }));
|
||||
} catch (error) {
|
||||
const diagnostic = nativeCookieDiagnostic(error, 'job_create');
|
||||
try { emptyJob?.close(); } catch {}
|
||||
writeFileSync(path.join(output, 'job-preflight.json'), JSON.stringify({ status: 'failed', diagnostic }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
incomplete('native_job_preflight_failed', diagnostic);
|
||||
}
|
||||
const memberProbe = await probeNativeCookieMember();
|
||||
const memberPreflight = 'cookies' in memberProbe ? { status: 'passed', activeProcesses: 0 } : { status: 'failed', error: memberProbe.error, diagnostic: memberProbe.diagnostic };
|
||||
writeFileSync(path.join(output, 'member-preflight.json'), JSON.stringify(memberPreflight, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeMemberPreflight: memberPreflight }));
|
||||
if ('error' in memberProbe) incomplete('native_member_preflight_failed', memberProbe.diagnostic);
|
||||
const root = path.resolve(import.meta.dir, '../..');
|
||||
let sourceHashes: Record<string, string>;
|
||||
try {
|
||||
sourceHashes = await nativeCodeHashes(root, Date.now() + 25_000);
|
||||
} catch {
|
||||
incomplete('source_inputs_unavailable_or_timed_out');
|
||||
}
|
||||
const environment = nativeCookieEnvironment(process.env);
|
||||
const browser = nativeBrowserPaths('Edge', environment);
|
||||
const executable = browser.executables.find(existsSync);
|
||||
const node = Bun.which('node');
|
||||
if (!executable || !node) incomplete('node_or_edge_not_installed');
|
||||
if (existsSync(browser.userDataDir)) incomplete('existing_default_profile_refused');
|
||||
const executableSha256 = await hashNativeFile(executable, Date.now() + 25_000);
|
||||
const nodeProbe = spawnSync(node, ['-p', 'JSON.stringify({ version: process.version, architecture: process.arch })'], { env: environment, encoding: 'utf8', timeout: 10_000, windowsHide: true });
|
||||
if (nodeProbe.status !== 0) incomplete('node_runtime_preflight_failed');
|
||||
const nodeInfo = JSON.parse(nodeProbe.stdout);
|
||||
if (nodeInfo.architecture !== process.arch || !['x64', 'arm64'].includes(process.arch)) incomplete('runtime_architecture_mismatch');
|
||||
const require = createRequire(import.meta.url);
|
||||
const playwrightVersion = require('playwright/package.json').version;
|
||||
if (playwrightVersion !== '1.62.1') incomplete('playwright_1_62_1_required');
|
||||
const versionProbe = spawnSync(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [
|
||||
'-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND,
|
||||
], { env: { ...environment, GSTACK_QUALIFY_BROWSER_EXE: executable }, encoding: 'utf8', timeout: 10_000, windowsHide: true });
|
||||
const versionErrorCode = (versionProbe.error as NodeJS.ErrnoException | undefined)?.code;
|
||||
const versionMetadata = {
|
||||
exitCode: versionProbe.status,
|
||||
signal: versionProbe.signal,
|
||||
spawnError: versionProbe.error ? (['ENOENT', 'EACCES', 'EPERM', 'ETIMEDOUT'].includes(versionErrorCode || '') ? versionErrorCode : 'spawn_failed') : undefined,
|
||||
stdoutBytes: Buffer.byteLength(versionProbe.stdout || ''),
|
||||
stderrBytes: Buffer.byteLength(versionProbe.stderr || ''),
|
||||
versionValid: /^\d+(?:\.\d+){2,3}$/.test((versionProbe.stdout || '').trim()),
|
||||
};
|
||||
writeFileSync(path.join(output, 'browser-version-preflight.json'), JSON.stringify(versionMetadata, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeBrowserVersionPreflight: versionMetadata }));
|
||||
if (versionProbe.status !== 0 || !versionMetadata.versionValid) incomplete('browser_version_preflight_failed');
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', 'test', 'browse/test/cookie-import-native-job.test.ts', '--test-name-pattern', '^native Windows process qualification'], {
|
||||
cwd: root,
|
||||
env: { ...environment, CI: 'true', GITHUB_ACTIONS: 'true', GSTACK_COOKIE_NATIVE_DEFAULT_FIXTURE: '1', NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
const log = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
writeFileSync(path.join(output, 'qualification.log'), log, { mode: 0o600, flag: 'wx' });
|
||||
const count = (kind: string) => Number([...log.matchAll(new RegExp(`(?:^|\\n)\\s*(\\d+) ${kind}\\b`, 'g'))].at(-1)?.[1] ?? 0);
|
||||
const counts = { pass: count('pass'), fail: count('fail'), skip: count('skip') };
|
||||
let inputsUnchanged = false;
|
||||
try {
|
||||
const deadline = Date.now() + 25_000;
|
||||
inputsUnchanged = await hashNativeFile(executable, deadline) === executableSha256 && nativeCodeMatches(sourceHashes, await nativeCodeHashes(root, deadline));
|
||||
} catch {}
|
||||
const passed = result.status === 0 && !result.error && counts.pass === 7 && counts.fail === 0 && counts.skip === 0 && inputsUnchanged && !existsSync(browser.userDataDir);
|
||||
const receipt = {
|
||||
status: passed ? 'passed' : 'failed',
|
||||
scope: 'Edge default-profile v20 extraction, pipe transport, profile identity, and owned-process cleanup',
|
||||
qualifiedBuild: {
|
||||
browserName: 'Edge',
|
||||
architecture: nodeInfo.architecture,
|
||||
windowsRelease: release(),
|
||||
executableSha256,
|
||||
nodeVersion: nodeInfo.version,
|
||||
bunVersion: Bun.version,
|
||||
playwrightVersion,
|
||||
sourceHashes,
|
||||
},
|
||||
browserVersion: versionProbe.stdout.trim(),
|
||||
supervisorArchitecture: process.arch,
|
||||
jobPreflight: { status: 'passed', activeProcesses: 0 },
|
||||
memberPreflight,
|
||||
counts,
|
||||
inputsUnchanged,
|
||||
sourceHashes,
|
||||
activationData: NATIVE_QUALIFICATION_DATA,
|
||||
activation: 'Review this receipt and add only this exact qualifiedBuild to the separate activation data; the runner never enables extraction and activation does not modify the recorded code hashes.',
|
||||
};
|
||||
writeFileSync(path.join(output, 'qualification.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ status: receipt.status, counts, artifactDirectory: output }));
|
||||
process.exitCode = passed ? 0 : 1;
|
||||
@@ -0,0 +1,436 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { release, tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { nativeBrowserPaths, importNativeCookies } from '../src/cookie-import-native';
|
||||
import { nativeCookieEnvironment, NATIVE_COOKIE_NODE_SCRIPT, NATIVE_PROGRESS_PREFIX } from '../src/cookie-import-native-worker';
|
||||
import { parseNativeCookieDiagnostic } from '../src/cookie-import-native-job';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, NATIVE_CODE_INPUTS, NATIVE_QUALIFICATION_DATA, readNativeQualifications } from '../src/cookie-import-native-integrity';
|
||||
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'native-cookies-'));
|
||||
const env = { LOCALAPPDATA: 'C:\\fixture\\Local', PROGRAMFILES: 'C:\\Apps', 'PROGRAMFILES(X86)': 'C:\\Apps32' };
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node is required for native-cookie transport tests');
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function isolatedEnv(): NodeJS.ProcessEnv {
|
||||
const local = path.join(root, 'AppData', 'Local');
|
||||
const roaming = path.join(root, 'AppData', 'Roaming');
|
||||
const temporary = path.join(local, 'Temp');
|
||||
for (const directory of [local, roaming, temporary]) mkdirSync(directory, { recursive: true });
|
||||
return {
|
||||
PATH: path.dirname(node!),
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
LOCALAPPDATA: local,
|
||||
APPDATA: roaming,
|
||||
TEMP: temporary,
|
||||
TMP: temporary,
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function expectNativeProgress(stderr: string) {
|
||||
const lines = stderr.trim().split(/\r?\n/).filter(Boolean);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
return lines.map(line => {
|
||||
expect(line.startsWith(NATIVE_PROGRESS_PREFIX)).toBe(true);
|
||||
const record = JSON.parse(line.slice(NATIVE_PROGRESS_PREFIX.length));
|
||||
expect(parseNativeCookieDiagnostic(record)).toEqual(record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function adapter(options: object, extraEnv: object = {}) {
|
||||
const script = `
|
||||
const { importNativeCookies } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'))});
|
||||
const { CookieImportError } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'))});
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
Object.defineProperty(process.versions, 'bun', { value: undefined });
|
||||
process.env.LOCALAPPDATA = ${JSON.stringify(env.LOCALAPPDATA)};
|
||||
Object.assign(process.env, ${JSON.stringify(extraEnv)});
|
||||
try { console.log(JSON.stringify({ cookies: await importNativeCookies(${JSON.stringify(options)}) })); }
|
||||
catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message, typed: error instanceof CookieImportError })); }
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
const options = {
|
||||
browserName: 'Edge',
|
||||
userDataDir: 'C:\\fixture\\Local\\Microsoft\\Edge\\User Data',
|
||||
profile: 'Default',
|
||||
domains: ['example.test'],
|
||||
};
|
||||
|
||||
describe('native-cookie production admission', () => {
|
||||
test('the real member entry reports boot and decoded input before refusing an unowned job', () => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts'), '--member-smoke'], {
|
||||
env: isolatedEnv(), input: JSON.stringify({ jobName: 'Local\\gstack-cookie-00000000-0000-0000-0000-000000000000' }), encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'worker_boot', memberMode: true });
|
||||
expect(progress).toContainEqual({ stage: 'member_input' });
|
||||
expect(progress).toContainEqual({ stage: 'member_decoded' });
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ error: 'native_supervision_failed', diagnostic: { stage: 'job_open' } });
|
||||
});
|
||||
|
||||
test.each([['malformed', '{'], ['oversized', 'x'.repeat(1024 * 1024 + 1)]])('the real member rejects %s input before opening a job', (_name, input) => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts'), '--member-smoke'], {
|
||||
env: isolatedEnv(), input, encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'member_input' });
|
||||
expect(progress).not.toContainEqual({ stage: 'member_decoded' });
|
||||
expect(progress).not.toContainEqual({ stage: 'job_open' });
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ error: 'native_supervision_failed', diagnostic: { stage: 'member_input' } });
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('qualification refuses non-Windows without issuing a receipt', () => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--config=/dev/null', path.resolve(import.meta.dir, 'cookie-import-native-qualification.ts'), root], {
|
||||
env: isolatedEnv(), encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('no cases ran and no qualification was issued');
|
||||
});
|
||||
|
||||
test('activation data is separate from the receipt code inputs', () => {
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-import-native.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-database.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-import-native-worker.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/scripts/build-node-server.sh');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/dist/server-node.mjs');
|
||||
expect(NATIVE_CODE_INPUTS).not.toContain(NATIVE_QUALIFICATION_DATA);
|
||||
});
|
||||
|
||||
test('empty selection never launches or widens to all cookies', async () => {
|
||||
expect(await importNativeCookies({ ...options, domains: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects unsupported production runtime', async () => {
|
||||
await expect(importNativeCookies(options)).rejects.toMatchObject({ code: 'not_supported' });
|
||||
});
|
||||
|
||||
test('rejects invalid domains before launching', async () => {
|
||||
for (const domain of ['https://example.test', 'example.test/path', '*', 'example.test\n--flag']) {
|
||||
await expect(importNativeCookies({ ...options, domains: [domain] })).rejects.toMatchObject({ code: 'invalid_domain' });
|
||||
}
|
||||
});
|
||||
|
||||
test('valid Chromium hostname forms reach runtime admission', async () => {
|
||||
for (const domain of ['service_name.example.test', '-service.example.test', '[::1]']) {
|
||||
await expect(importNativeCookies({ ...options, domains: [domain] })).rejects.toMatchObject({ code: 'not_supported' });
|
||||
}
|
||||
});
|
||||
|
||||
test('unqualified Windows extraction is closed with a typed safe error', () => {
|
||||
expect(adapter(options)).toMatchObject({ code: 'native_unqualified', typed: true });
|
||||
});
|
||||
|
||||
test('environment flags cannot activate unqualified extraction', () => {
|
||||
expect(adapter(options, { GSTACK_COOKIE_NATIVE_QUALIFY: '1', GSTACK_NATIVE_COOKIES: '1' })).toMatchObject({ code: 'native_unqualified', typed: true });
|
||||
});
|
||||
|
||||
test('Chrome default user-data policy also blocks numbered profiles', () => {
|
||||
const result = adapter({ ...options, browserName: 'Chrome', userDataDir: 'C:\\fixture\\Local\\Google\\Chrome\\User Data', profile: 'Profile 2' });
|
||||
expect(result.code).toBe('native_profile_unsupported');
|
||||
expect(result.message).toContain('Chrome 136');
|
||||
expect(result.message).toContain('both pipe and TCP');
|
||||
expect(result.message).toContain('sign in manually');
|
||||
});
|
||||
|
||||
test('does not substitute a different browser for an unsupported identity', () => {
|
||||
expect(adapter({ ...options, browserName: 'Dia' })).toMatchObject({ code: 'not_supported', typed: true });
|
||||
});
|
||||
|
||||
test('rejects mismatched and copied profile roots', () => {
|
||||
expect(adapter({ ...options, browserName: 'Brave' })).toMatchObject({ code: 'native_profile_unsupported' });
|
||||
expect(adapter({ ...options, userDataDir: 'C:\\copied-profile' })).toMatchObject({ code: 'native_profile_unsupported' });
|
||||
});
|
||||
|
||||
test('rejects profile traversal without echoing the supplied text', () => {
|
||||
const result = adapter({ ...options, profile: '../sensitive-sentinel' });
|
||||
expect(result.code).toBe('invalid_profile');
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test('maps every supported browser to only its own executable and data root', () => {
|
||||
const expected = [
|
||||
['Chrome', 'Google\\Chrome', 'chrome.exe'],
|
||||
['Chromium', 'Chromium', 'chrome.exe'],
|
||||
['Brave', 'BraveSoftware\\Brave-Browser', 'brave.exe'],
|
||||
['Edge', 'Microsoft\\Edge', 'msedge.exe'],
|
||||
];
|
||||
for (const [name, relative, exe] of expected) {
|
||||
const mapping = nativeBrowserPaths(name, env);
|
||||
expect(mapping.userDataDir).toBe(`${env.LOCALAPPDATA}\\${relative}\\User Data`);
|
||||
expect(mapping.executables.length).toBeGreaterThan(0);
|
||||
for (const candidate of mapping.executables) expect(candidate.endsWith(`\\${relative}\\Application\\${exe}`)).toBe(true);
|
||||
}
|
||||
expect(nativeBrowserPaths('Edge', env).executables[0]).toStartWith('C:\\Apps32');
|
||||
expect(nativeBrowserPaths('Brave', env).executables.join(' ')).not.toContain('chrome.exe');
|
||||
});
|
||||
|
||||
test('browser environment excludes inherited secrets and runtime injection', () => {
|
||||
expect(nativeCookieEnvironment({ ...env, SystemRoot: 'C:\\Windows', API_KEY: 'sensitive-sentinel', NODE_OPTIONS: '--require=unsafe', DEBUG: 'pw:*', PWDEBUG: '1' })).toEqual({ ...env, SystemRoot: 'C:\\Windows' });
|
||||
});
|
||||
});
|
||||
|
||||
async function qualifiedAdapterFixture() {
|
||||
const fixture = mkdtempSync(path.join(root, 'admission-'));
|
||||
for (const file of NATIVE_CODE_INPUTS) {
|
||||
const destination = path.join(fixture, file);
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
if (file === 'browse/dist/server-node.mjs') continue;
|
||||
copyFileSync(path.resolve(import.meta.dir, '../..', file === 'browse/dist/bun-polyfill.cjs' ? 'browse/src/bun-polyfill.cjs' : file), destination);
|
||||
}
|
||||
const activation = path.join(fixture, NATIVE_QUALIFICATION_DATA);
|
||||
writeFileSync(activation, '[]\n');
|
||||
const bundle = path.join(fixture, 'browse/dist/server-node.mjs');
|
||||
const build = async () => {
|
||||
const result = await Bun.build({
|
||||
entrypoints: [path.join(fixture, 'browse/src/cookie-import-native.ts')],
|
||||
target: 'node',
|
||||
define: { 'import.meta.dir': JSON.stringify(path.join(fixture, 'browse/src')) },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
writeFileSync(bundle, await result.outputs[0].text());
|
||||
};
|
||||
await build();
|
||||
const probe = spawnSync(node!, ['-p', 'JSON.stringify({ version: process.version, architecture: process.arch })'], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(probe.status).toBe(0);
|
||||
const runtime = JSON.parse(probe.stdout);
|
||||
const sourceHashes = await nativeCodeHashes(fixture, Date.now() + 25_000);
|
||||
const receipt = {
|
||||
browserName: 'Edge', architecture: runtime.architecture, windowsRelease: release(),
|
||||
executableSha256: '0'.repeat(64), nodeVersion: runtime.version, bunVersion: Bun.version,
|
||||
playwrightVersion: '1.62.1', sourceHashes,
|
||||
};
|
||||
const virtualRoot = `C:\\gstack-${path.basename(fixture)}`;
|
||||
const windowsEnv = { LOCALAPPDATA: `${virtualRoot}\\Local`, PROGRAMFILES: `${virtualRoot}\\Apps`, 'PROGRAMFILES(X86)': `${virtualRoot}\\Apps32` };
|
||||
const input = { ...options, userDataDir: nativeBrowserPaths('Edge', windowsEnv).userDataDir };
|
||||
const invoke = () => {
|
||||
const script = `
|
||||
import cp from 'node:child_process';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
let spawns = 0;
|
||||
cp.spawn = () => { spawns++; throw new Error('Unexpected browser launch before source admission'); };
|
||||
syncBuiltinESMExports();
|
||||
const { importNativeCookies } = await import(${JSON.stringify(pathToFileURL(bundle).href)});
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
Object.assign(process.env, ${JSON.stringify(windowsEnv)});
|
||||
try { await importNativeCookies(${JSON.stringify(input)}); console.log(JSON.stringify({ accepted: true, spawns })); }
|
||||
catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message, spawns, typed: error.name === 'CookieImportError' })); }
|
||||
`;
|
||||
const result = spawnSync(node!, ['--input-type=module', '-e', script], { cwd: fixture, env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
return { fixture, activation, bundle, build, receipt, invoke };
|
||||
}
|
||||
|
||||
describe('production adapter source-bound qualification', () => {
|
||||
test('matching code passes admission, while later worker/core/database/bundle edits reject the old receipt', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0, typed: true });
|
||||
writeFileSync(fixture.activation, JSON.stringify([fixture.receipt]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0, typed: true });
|
||||
for (const file of ['browse/src/cookie-import-native-worker.ts', 'browse/src/cookie-import-native-job.ts', 'browse/src/cookie-import-native-integrity.ts', 'browse/src/cookie-import-browser.ts', 'browse/src/cookie-database.ts', 'browse/scripts/build-node-server.sh', 'browse/dist/server-node.mjs']) {
|
||||
const target = path.join(fixture.fixture, file);
|
||||
const original = readFileSync(target);
|
||||
writeFileSync(target, Buffer.concat([original, Buffer.from('\n')]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0, typed: true });
|
||||
writeFileSync(target, original);
|
||||
}
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0, typed: true });
|
||||
});
|
||||
|
||||
test('activation-only edits preserve all bound bytes, including a rebuilt Node bundle', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
const before = readFileSync(fixture.bundle);
|
||||
writeFileSync(fixture.activation, JSON.stringify([fixture.receipt], null, 2) + '\n');
|
||||
await fixture.build();
|
||||
expect(readFileSync(fixture.bundle)).toEqual(before);
|
||||
expect(await nativeCodeHashes(fixture.fixture, Date.now() + 25_000)).toEqual(fixture.receipt.sourceHashes);
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0 });
|
||||
});
|
||||
|
||||
test('partial or legacy receipts cannot activate even when their browser/runtime tuple matches', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
const sourceHashes = { ...fixture.receipt.sourceHashes };
|
||||
delete sourceHashes['browse/src/cookie-database.ts'];
|
||||
writeFileSync(fixture.activation, JSON.stringify([{ ...fixture.receipt, sourceHashes }]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0 });
|
||||
writeFileSync(fixture.activation, JSON.stringify([{ ...fixture.receipt, sourceHashes: undefined }]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0 });
|
||||
expect(nativeCodeMatches(sourceHashes, fixture.receipt.sourceHashes)).toBe(false);
|
||||
});
|
||||
|
||||
test('qualification reads have a size cap and share the operation deadline', async () => {
|
||||
const fixture = mkdtempSync(path.join(root, 'bounds-'));
|
||||
const activation = path.join(fixture, NATIVE_QUALIFICATION_DATA);
|
||||
mkdirSync(path.dirname(activation), { recursive: true });
|
||||
writeFileSync(activation, ' '.repeat(1024 * 1024 + 1));
|
||||
await expect(readNativeQualifications(fixture, Date.now() + 25_000)).rejects.toThrow('native_unqualified');
|
||||
await expect(hashNativeFile(activation, Date.now() - 1)).rejects.toThrow('native_timeout');
|
||||
});
|
||||
|
||||
test('a stalled source read aborts and destroys its stream at the deadline', async () => {
|
||||
const fixture = mkdtempSync(path.join(root, 'stalled-read-'));
|
||||
const entry = path.join(fixture, 'integrity.mjs');
|
||||
const built = await Bun.build({ entrypoints: [path.resolve(import.meta.dir, '../src/cookie-import-native-integrity.ts')], target: 'node' });
|
||||
expect(built.success).toBe(true);
|
||||
writeFileSync(entry, await built.outputs[0].text());
|
||||
const script = `
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
let aborted = false;
|
||||
let closed = false;
|
||||
fs.createReadStream = (file, options) => {
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.once('close', () => { closed = true; });
|
||||
options.signal.addEventListener('abort', () => { aborted = true; stream.destroy(new Error('synthetic interruption')); }, { once: true });
|
||||
return stream;
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
const { hashNativeFile } = await import(${JSON.stringify(pathToFileURL(entry).href)});
|
||||
const started = Date.now();
|
||||
let code;
|
||||
try { await hashNativeFile('synthetic-source', started + 20); }
|
||||
catch (error) { code = error.message; }
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
console.log(JSON.stringify({ code, aborted, closed, elapsed: Date.now() - started }));
|
||||
`;
|
||||
const result = spawnSync(node!, ['--input-type=module', '-e', script], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const outcome = JSON.parse(result.stdout);
|
||||
expect(outcome).toMatchObject({ code: 'native_timeout', aborted: true, closed: true });
|
||||
expect(outcome.elapsed).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
|
||||
function runNodeWorker(mode: string, cookies: object[] = [], exitCode = 21) {
|
||||
const fixture = mkdtempSync(path.join(root, 'worker-'));
|
||||
const observation = path.join(fixture, 'observed.json');
|
||||
const playwrightEntry = path.join(fixture, 'playwright.cjs');
|
||||
writeFileSync(playwrightEntry, `
|
||||
exports.chromium = {
|
||||
async launchPersistentContext(userDataDir, options) {
|
||||
require('node:fs').writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ userDataDir, options, nodeVersion: process.version }));
|
||||
if (${JSON.stringify(mode)} === 'locked') throw new Error('ProcessSingleton sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'policy') throw new Error('Remote debugging requires a non-default data directory sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'failure') throw new Error('sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'process-exit') throw new Error('<process did exit: exitCode=${exitCode}, signal=null> sensitive-sentinel');
|
||||
return { cookies: async () => ${JSON.stringify(cookies)}, close: async () => {} };
|
||||
},
|
||||
};
|
||||
`);
|
||||
const userDataDir = path.join(fixture, 'profile');
|
||||
mkdirSync(userDataDir);
|
||||
const result = spawnSync(node!, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: isolatedEnv(),
|
||||
input: JSON.stringify({ playwrightEntry, userDataDir, executablePath: 'C:\\Apps\\Microsoft\\Edge\\Application\\msedge.exe', profile: 'Profile 2', domains: ['EXAMPLE.TEST.'], deadline: Date.now() + 25_000 }),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'node_input' });
|
||||
expect(progress).toContainEqual({ stage: 'node_load' });
|
||||
return { result: JSON.parse(result.stdout), observation: JSON.parse(readFileSync(observation, 'utf8')), userDataDir };
|
||||
}
|
||||
|
||||
describe('production Node Playwright worker', () => {
|
||||
test('real synthetic Playwright pipe smoke uses no TCP and leaves no browser', () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('playwright');
|
||||
const fixture = mkdtempSync(path.join(root, 'pipe-'));
|
||||
const observation = path.join(fixture, 'launch.json');
|
||||
const playwrightEntry = path.join(fixture, 'instrumented-playwright.cjs');
|
||||
writeFileSync(playwrightEntry, `
|
||||
const cp = require('node:child_process');
|
||||
const spawn = cp.spawn;
|
||||
cp.spawn = function(command, args, options) {
|
||||
const child = spawn.call(this, command, args, options);
|
||||
require('node:fs').writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ args, pid: child.pid }));
|
||||
return child;
|
||||
};
|
||||
const { chromium } = require(${JSON.stringify(require.resolve('playwright'))});
|
||||
exports.chromium = { async launchPersistentContext(root, options) {
|
||||
const context = await chromium.launchPersistentContext(root, options);
|
||||
await context.addCookies([{ name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' }]);
|
||||
return context;
|
||||
} };
|
||||
`);
|
||||
const result = spawnSync(node!, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: isolatedEnv(),
|
||||
input: JSON.stringify({ playwrightEntry, userDataDir: path.join(fixture, 'User Data'), executablePath: chromium.executablePath(), profile: 'Default', domains: ['example.test'], deadline: Date.now() + 25_000 }),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'browser_launch' });
|
||||
expect(progress).toContainEqual({ stage: 'cookie_read' });
|
||||
expect(JSON.parse(result.stdout).cookies).toHaveLength(1);
|
||||
const launched = JSON.parse(readFileSync(observation, 'utf8'));
|
||||
expect(launched.args).toContain('--remote-debugging-pipe');
|
||||
expect(launched.args.some((arg: string) => arg.startsWith('--remote-debugging-port'))).toBe(false);
|
||||
expect(launched.args.some((arg: string) => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))).toBe(false);
|
||||
expect(launched.args).toContain(`--user-data-dir=${path.join(fixture, 'User Data')}`);
|
||||
expect(() => process.kill(launched.pid, 0)).toThrow();
|
||||
}, 35_000);
|
||||
|
||||
test('launches the requested profile once through Playwright with no TCP or bypass arguments', () => {
|
||||
const { result, observation, userDataDir } = runNodeWorker('success');
|
||||
expect(result).toEqual({ cookies: [] });
|
||||
expect(observation.userDataDir).toBe(userDataDir);
|
||||
expect(observation.options).toMatchObject({
|
||||
executablePath: 'C:\\Apps\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
args: ['--profile-directory=Profile 2'],
|
||||
handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,
|
||||
headless: true,
|
||||
chromiumSandbox: true,
|
||||
});
|
||||
expect(observation.options.timeout).toBeGreaterThan(0);
|
||||
expect(observation.options.timeout).toBeLessThanOrEqual(25_000);
|
||||
expect(observation.nodeVersion).toStartWith('v');
|
||||
expect(Object.keys(observation.options.env)).not.toContain('NODE_OPTIONS');
|
||||
});
|
||||
|
||||
test('bare/dotted matching preserves scope and cookie attributes', () => {
|
||||
const cookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/', httpOnly: true, secure: true, sameSite: 'Lax', expires: -1 };
|
||||
const selected = [cookie, { ...cookie, domain: '.example.test' }];
|
||||
const { result } = runNodeWorker('success', [...selected, { ...cookie, domain: 'other.example.test' }, { ...cookie, domain: 'badexample.test' }]);
|
||||
expect(result.cookies).toEqual(selected);
|
||||
});
|
||||
|
||||
test.each([['locked', 'browser_running'], ['policy', 'native_profile_unsupported'], ['failure', 'native_failed']])('classifies %s without leaking browser stderr or retrying', (mode, error) => {
|
||||
const { result } = runNodeWorker(mode);
|
||||
expect(result).toEqual({ error, diagnostic: { stage: 'browser_launch' } });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test('reports only the managed browser exit code, not raw launch errors', () => {
|
||||
const { result } = runNodeWorker('process-exit');
|
||||
expect(result).toEqual({ error: 'browser_running', diagnostic: { stage: 'browser_launch', exitCode: 21 } });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test.each([0, 1, 20, 22, 24, -1, -1073741819])('does not classify unrelated browser exit %d as a profile lock', exitCode => {
|
||||
const { result } = runNodeWorker('process-exit', [], exitCode);
|
||||
expect(result).toEqual({ error: 'native_failed', diagnostic: { stage: 'browser_launch', exitCode } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-node-')));
|
||||
const bundle = path.join(root, 'importer.mjs');
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node.js is required for importer runtime coverage');
|
||||
|
||||
beforeAll(() => {
|
||||
const build = spawnSync(process.execPath, ['build', path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'), '--target=node', '--outfile', bundle], {
|
||||
encoding: 'utf8', timeout: 30_000,
|
||||
});
|
||||
expect(build.status).toBe(0);
|
||||
const profile = path.join(root, '.config/chromium/Default');
|
||||
mkdirSync(profile, { recursive: true });
|
||||
expect(realpathSync(profile).startsWith(root + path.sep)).toBe(true);
|
||||
const dbPath = path.join(profile, 'Cookies');
|
||||
const database = new Database(dbPath);
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
database.run("INSERT INTO cookies VALUES ('.fixture.test', 'synthetic', 'fixture-value', x'', '/', 0, 0, 1, 0, 1)");
|
||||
database.close();
|
||||
const windows = path.join(root, 'AppData/Local/Chromium/User Data/Default');
|
||||
mkdirSync(windows, { recursive: true });
|
||||
expect(realpathSync(windows).startsWith(root + path.sep)).toBe(true);
|
||||
copyFileSync(dbPath, path.join(windows, 'Cookies'));
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('actual Node importer runtime', () => {
|
||||
test('lists and imports cookies through the bundled production module', () => {
|
||||
const child = spawnSync(node!, ['--input-type=module', '-e', `
|
||||
const { listDomains, importCookies } = await import(process.argv[1]);
|
||||
const domains = listDomains('chromium');
|
||||
const result = await importCookies('chromium', ['fixture.test']);
|
||||
console.log(JSON.stringify({ domains, count: result.count, failed: result.failed, scope: Object.keys(result.domainCounts), cookieName: result.cookies[0]?.name }));
|
||||
`, pathToFileURL(bundle).href], {
|
||||
encoding: 'utf8', timeout: 15_000,
|
||||
env: { HOME: root, USERPROFILE: root, LOCALAPPDATA: path.join(root, 'AppData/Local'), TEMP: root, TMP: root, NODE_NO_WARNINGS: '1', PATH: path.dirname(node!), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
||||
});
|
||||
expect(child.error).toBeUndefined();
|
||||
expect(child.status).toBe(0);
|
||||
expect(child.stderr).toBe('');
|
||||
expect(JSON.parse(child.stdout)).toEqual({ domains: { browser: 'Chromium', domains: [{ domain: '.fixture.test', count: 1 }] }, count: 1, failed: 0, scope: ['.fixture.test'], cookieName: 'synthetic' });
|
||||
});
|
||||
|
||||
test('Node server build does not stub away the database', () => {
|
||||
const script = readFileSync(path.resolve(import.meta.dir, '../scripts/build-node-server.sh'), 'utf8');
|
||||
expect(script).not.toContain('const Database = null');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { getCookieProfiles, parseCookieImportArgs, runCookieImport } from '../src/cookie-import-operation';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
import { handleReadCommand } from '../src/read-commands';
|
||||
import { handleWriteCommand } from '../src/write-commands';
|
||||
import * as importer from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let homeMock: ReturnType<typeof spyOn>;
|
||||
let currentUrl: string;
|
||||
let page: any;
|
||||
let context: any;
|
||||
let session: any;
|
||||
let bm: BrowserManager;
|
||||
let cdp: any;
|
||||
|
||||
function installProfile(name = 'Default', domain = '.example.test', badCookie = false) {
|
||||
const dir = path.join(home, '.config/chromium', name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [domain, 'session', 'synthetic-session', Buffer.alloc(0), '/']);
|
||||
if (badCookie) db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [domain, 'broken', '', Buffer.from('v20synthetic'), '/']);
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function route(method: string, pathname: string, body?: unknown, cookie?: string) {
|
||||
const url = new URL('http://127.0.0.1:9470/cookie-picker' + pathname);
|
||||
let pickerInstance = '';
|
||||
if (cookie) {
|
||||
const document = await handleCookiePickerRoute(new URL(url.origin + '/cookie-picker'), new Request(url.origin + '/cookie-picker', {
|
||||
headers: { Cookie: cookie },
|
||||
}), bm, 'fixture');
|
||||
const html = await document.text();
|
||||
pickerInstance = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]).pickerInstance;
|
||||
}
|
||||
return handleCookiePickerRoute(url, new Request(url, {
|
||||
method,
|
||||
headers: cookie ? { Cookie: cookie, Origin: url.origin, 'Content-Type': 'application/json', 'X-Gstack-Picker-Instance': pickerInstance } : { Authorization: 'Bearer fixture', 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}), bm, 'fixture');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-op-'));
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
currentUrl = 'https://example.test/protected';
|
||||
const cdpEvents = new EventEmitter();
|
||||
const pageEvents = new EventEmitter();
|
||||
const frame = {};
|
||||
cdp = {
|
||||
on: cdpEvents.on.bind(cdpEvents), off: cdpEvents.off.bind(cdpEvents), detach: mock(async () => {}),
|
||||
send: mock(async (method: string, params: any) => {
|
||||
if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'fixture-frame' } } };
|
||||
if (method === 'Runtime.enable') return {};
|
||||
if (method === 'Page.createIsolatedWorld') {
|
||||
cdpEvents.emit('Runtime.executionContextCreated', { context: { name: params.worldName, id: 7, uniqueId: 'fixture-world', auxData: { frameId: 'fixture-frame', isDefault: false } } });
|
||||
return { executionContextId: 7 };
|
||||
}
|
||||
if (method === 'Runtime.evaluate') return { result: { value: 100 } };
|
||||
if (method === 'Runtime.callFunctionOn') return { result: { value: 'cleared' } };
|
||||
throw new Error('Unexpected protocol method');
|
||||
}),
|
||||
};
|
||||
context = { addCookies: mock(async () => {}), clearCookies: mock(async () => {}),
|
||||
browser: () => ({ browserType: () => ({ name: () => 'chromium' }) }), newCDPSession: mock(async () => cdp) };
|
||||
page = {
|
||||
url: () => currentUrl,
|
||||
isClosed: () => false,
|
||||
context: () => context,
|
||||
evaluate: mock(async () => 'cleared'),
|
||||
reload: mock(async () => { throw new Error('Should not reload'); }),
|
||||
mainFrame: () => frame, on: pageEvents.on.bind(pageEvents), off: pageEvents.off.bind(pageEvents),
|
||||
};
|
||||
session = { getPage: () => page, getFrame: () => null, getActiveFrameOrPage: () => page };
|
||||
bm = new BrowserManager();
|
||||
spyOn(bm, 'getActiveSession').mockReturnValue(session);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
homeMock.mockRestore();
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 300_000 + 3_600_000 + 1;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('cookie picker mutation origin policy', () => {
|
||||
for (const action of ['import', 'remove']) {
|
||||
for (const origin of [undefined, 'null', 'http://127.0.0.1:9988', 'http://localhost:9470', 'https://attacker.test']) {
|
||||
test(`${action} rejects session mutation from ${origin ?? 'missing origin'} before touching the target`, async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchanged = await route('GET', `?code=${code}`);
|
||||
const cookie = exchanged.headers.get('set-cookie')!;
|
||||
const url = new URL(`http://127.0.0.1:9470/cookie-picker/${action}`);
|
||||
const response = await handleCookiePickerRoute(url, new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'Sec-Fetch-Site': 'same-site', ...(origin === undefined ? {} : { Origin: origin }) },
|
||||
body: JSON.stringify({ browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }),
|
||||
}), bm, 'fixture');
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).code).toBe('invalid_origin');
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.clearCookies).not.toHaveBeenCalled();
|
||||
expect(cdp.send).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test(`${action} accepts the real picker origin and preserves bearer authorization without browser headers`, async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchanged = await route('GET', `?code=${code}`);
|
||||
const cookie = exchanged.headers.get('set-cookie')!;
|
||||
const body = { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true };
|
||||
expect((await route('POST', `/${action}`, body, cookie)).status).toBe(200);
|
||||
expect((await route('POST', `/${action}`, body)).status).toBe(200);
|
||||
if (action === 'import') {
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(2);
|
||||
expect(cdp.send.mock.calls.filter(([method]: [string]) => method === 'Runtime.callFunctionOn')).toHaveLength(2);
|
||||
} else {
|
||||
expect(context.clearCookies).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('cookie import argument and selection policy', () => {
|
||||
test('parses flag values independently from the browser position', () => {
|
||||
expect(parseCookieImportArgs(['--domain', '.EXAMPLE.TEST', 'chromium', '--profile', 'Profile 2'])).toEqual({ browser: 'chromium', domains: ['example.test'], profile: 'Profile 2' });
|
||||
expect(parseCookieImportArgs(['--domain', 'example.test'])).toEqual({ browser: 'comet', domains: ['example.test'] });
|
||||
});
|
||||
|
||||
test('rejects missing duplicate unknown and incompatible options', () => {
|
||||
for (const args of [['--domain'], ['--profile', '--all'], ['--unknown'], ['chrome', 'edge'], ['--all', '--domain', 'example.test'], ['--all', '--clear-storage'], ['--all', '--all']]) {
|
||||
expect(() => parseCookieImportArgs(args)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('recommends a unique domain match without overriding explicit profiles', async () => {
|
||||
installProfile();
|
||||
installProfile('Profile 2', '.other.test');
|
||||
expect((await getCookieProfiles('chromium', ['example.test'])).recommendedProfile).toBe('Default');
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Profile 2', domains: ['other.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(result.profile).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('does not guess among matching or unreadable profiles', async () => {
|
||||
installProfile();
|
||||
installProfile('Profile 2');
|
||||
expect((await getCookieProfiles('chromium', ['example.test'])).recommendedProfile).toBeUndefined();
|
||||
await expect(runCookieImport({ browser: 'chromium', domains: ['example.test'] }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'profile_required' });
|
||||
fs.writeFileSync(path.join(home, '.config/chromium/Profile 2/Cookies'), 'not a database');
|
||||
const profiles = await getCookieProfiles('chromium', ['example.test']);
|
||||
expect(profiles.recommendedProfile).toBeUndefined();
|
||||
expect(profiles.profiles.find(profile => profile.name === 'Profile 2')?.unavailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered import callers', () => {
|
||||
test('picker applies cookies and activates the actual downstream JS-origin guard', async () => {
|
||||
installProfile();
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] });
|
||||
expect(response.status).toBe(200);
|
||||
const receipt = await response.json();
|
||||
expect(receipt.imported).toBe(1);
|
||||
expect(receipt.verification.reason).toBe('not_requested');
|
||||
expect(bm.getCookieImportedDomains().has('.example.test')).toBe(true);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
currentUrl = 'https://unrelated.test/';
|
||||
await expect(handleReadCommand('js', ['document.cookie'], session, bm)).rejects.toThrow('JS execution blocked');
|
||||
});
|
||||
|
||||
test('direct command imports both domain spellings and preserves the domain guard', async () => {
|
||||
installProfile();
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['--domain', 'example.test', 'chromium'], session, bm);
|
||||
expect(result).toContain('Imported 1 cookies');
|
||||
expect(result).toContain('Authentication: not_requested');
|
||||
expect(bm.hasCookieImports()).toBe(true);
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--domain', 'other.test'], session, bm)).rejects.toMatchObject({ code: 'target_mismatch' });
|
||||
});
|
||||
|
||||
test('returns partial and zero receipts without exposing cookie values', async () => {
|
||||
installProfile('Default', '.example.test', true);
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] });
|
||||
const text = await response.text();
|
||||
const receipt = JSON.parse(text);
|
||||
expect(receipt.outcome).toBe('partial');
|
||||
expect(receipt.imported).toBe(1);
|
||||
expect(receipt.failed).toBe(1);
|
||||
expect(text).not.toContain('synthetic-session');
|
||||
const empty = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['missing.test'] });
|
||||
expect(await empty.json()).toMatchObject({ imported: 0, outcome: 'empty' });
|
||||
});
|
||||
|
||||
test('native fallback cannot erase unresolved source-cookie failures', async () => {
|
||||
installProfile();
|
||||
const db = new Database(path.join(home, '.config/chromium/Default/Cookies'));
|
||||
db.run("UPDATE cookies SET value = '', encrypted_value = ?", [Buffer.from('v20synthetic')]);
|
||||
db.close();
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const native = spyOn(importer, 'importCookiesViaCdp').mockResolvedValue({ cookies: [], count: 0, failed: 0, domainCounts: {} });
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
try {
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(native).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({ imported: 0, failed: 1, outcome: 'failed', failureReasons: { native_unrecovered: 1 } });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
native.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('all-domain mode requires explicit consent and never resets storage', async () => {
|
||||
installProfile();
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['chromium', '--profile', 'Default', '--all'], session, bm);
|
||||
expect(result).toContain('Used --all');
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--all', '--clear-storage'], session, bm)).rejects.toMatchObject({ code: 'bad_request' });
|
||||
});
|
||||
|
||||
test('preflights requested verification before importing or resetting', async () => {
|
||||
installProfile();
|
||||
await expect(runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], verifyAuth: true, clearStorage: true }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'verification_not_configured' });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects unsupported reset before reading profiles or applying cookies', async () => {
|
||||
context.browser = () => ({ browserType: () => ({ name: () => 'firefox' }) });
|
||||
await expect(runCookieImport({ browser: 'chromium', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'storage_reset_unsupported' });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ordinary imports on other target engines remain available', async () => {
|
||||
installProfile();
|
||||
context.browser = () => ({ browserType: () => ({ name: () => 'webkit' }) });
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(result.imported).toBe(1);
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('zero import never resets or reports verified', async () => {
|
||||
installProfile();
|
||||
currentUrl = 'https://empty.example.test/';
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['empty.example.test'], verifyAuth: true }, { page, url: currentUrl }, () => {}, { identitySelector: '.identity', expectedIdentity: 'Synthetic' });
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.verification.verified).toBe(false);
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('explicit reset precedes application and reports later application failure', async () => {
|
||||
installProfile();
|
||||
context.addCookies.mockImplementation(async () => { throw new Error('synthetic-sensitive-error'); });
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, domains => bm.trackCookieImportDomains(domains));
|
||||
expect(cdp.send.mock.calls.filter(([method]: [string]) => method === 'Runtime.callFunctionOn')).toHaveLength(1);
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ reset: 'cleared', imported: 0, outcome: 'failed' });
|
||||
expect(JSON.stringify(result)).not.toContain('synthetic-sensitive-error');
|
||||
expect(bm.hasCookieImports()).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a target switch during the import before a reset', async () => {
|
||||
installProfile();
|
||||
const pending = runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, () => {});
|
||||
currentUrl = 'https://other.test/';
|
||||
await expect(pending).rejects.toMatchObject({ code: 'target_changed' });
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('serializes context mutations instead of replaying duplicate imports', async () => {
|
||||
installProfile();
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
context.addCookies.mockImplementation(() => blocked.promise);
|
||||
const first = runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
await expect(runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'import_busy' });
|
||||
blocked.resolve();
|
||||
expect((await first).imported).toBe(1);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('picker session retains its captured destination instead of the newly active page', async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchange = await route('GET', '?code=' + code);
|
||||
const cookie = exchange.headers.get('set-cookie')!.split(';')[0];
|
||||
const otherAdd = mock(async () => {});
|
||||
spyOn(bm, 'getActiveSession').mockReturnValue({ getPage: () => ({ context: () => ({ addCookies: otherAdd }), url: () => 'https://other.test/' }) } as any);
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] }, cookie);
|
||||
expect((await response.json()).imported).toBe(1);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(otherAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('one-time picker codes last five minutes and fail at the expiry boundary', async () => {
|
||||
const now = Date.now;
|
||||
const start = now();
|
||||
const code = generatePickerCode();
|
||||
Date.now = () => start + 299_999;
|
||||
try { expect((await route('GET', '?code=' + code)).status).toBe(302); } finally { Date.now = now; }
|
||||
const expired = generatePickerCode();
|
||||
Date.now = () => now() + 300_000;
|
||||
try { expect((await route('GET', '?code=' + expired)).status).toBe(403); } finally { Date.now = now; }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { findInstalledBrowsers, importCookies, listProfiles, cookieDomainMatches, CookieImportError, normalizeCookieDomain, withCookieReadRetry } from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let oldHome: string | undefined;
|
||||
let oldUserProfile: string | undefined;
|
||||
let spawn: typeof Bun.spawn;
|
||||
let homeMock: ReturnType<typeof spyOn>;
|
||||
|
||||
function profile(dir: string, name: string, cookieDomain = '.example.test') {
|
||||
const target = path.join(home, dir, name);
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
expect(fs.realpathSync(target).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(target, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [cookieDomain, 'fixture', 'synthetic-value', Buffer.alloc(0), '/']);
|
||||
db.close();
|
||||
return target;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-wave-'));
|
||||
oldHome = process.env.HOME;
|
||||
oldUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
spawn = Bun.spawn;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
if (!['secret-tool', 'security'].includes(command[0])) throw new Error('Unexpected fixture subprocess');
|
||||
return { stdout: new Blob(['fixture-password']).stream(), stderr: new Blob([]).stream(), exited: Promise.resolve(0), kill() {} };
|
||||
}) as typeof Bun.spawn;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Bun.spawn = spawn;
|
||||
homeMock.mockRestore();
|
||||
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
||||
if (oldUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = oldUserProfile;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('cookie import reliability', () => {
|
||||
test('discovers Dia with only a numbered macOS profile', () => {
|
||||
profile('Library/Application Support/Dia/User Data', 'Profile 2');
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Dia');
|
||||
expect(listProfiles('dia')[0].name).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('prefers current Local State names and sorts profile numbers naturally', () => {
|
||||
const root = '.config/chromium';
|
||||
for (const name of ['Profile 10', 'Profile 2', 'Default']) {
|
||||
const dir = profile(root, name);
|
||||
fs.writeFileSync(path.join(dir, 'Preferences'), JSON.stringify({ profile: { name: 'Old name' } }));
|
||||
}
|
||||
fs.writeFileSync(path.join(home, root, 'Local State'), JSON.stringify({ profile: { info_cache: { 'Profile 2': { name: 'Current name' } } } }));
|
||||
expect(listProfiles('chromium')).toEqual([
|
||||
{ name: 'Default', displayName: 'Old name' },
|
||||
{ name: 'Profile 2', displayName: 'Current name' },
|
||||
{ name: 'Profile 10', displayName: 'Old name' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('malformed metadata preserves the directory identity', () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
fs.writeFileSync(path.join(home, '.config/chromium/Local State'), '{');
|
||||
fs.writeFileSync(path.join(dir, 'Preferences'), '{');
|
||||
expect(listProfiles('chromium')).toEqual([{ name: 'Default', displayName: 'Default' }]);
|
||||
});
|
||||
|
||||
test('bare and dotted domain selection import the same stored row without widening scope', async () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("INSERT INTO cookies VALUES ('evil-example.test', 'other', 'synthetic-other', x'', '/', 0, 1, 1, 0, 1)");
|
||||
db.close();
|
||||
for (const domain of ['example.test', '.example.test', 'EXAMPLE.TEST.']) {
|
||||
const result = await importCookies('chromium', [domain]);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.cookies[0].domain).toBe('.example.test');
|
||||
}
|
||||
});
|
||||
|
||||
test('reports partial decrypt reasons without error or cookie values', async () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("INSERT INTO cookies VALUES ('.example.test', 'broken', '', ?, '/', 0, 1, 1, 0, 1)", [Buffer.from('v20synthetic')]);
|
||||
db.close();
|
||||
const result = await importCookies('chromium', ['example.test']);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.failureReasons).toEqual({ unsupported_encryption: 1 });
|
||||
});
|
||||
|
||||
test('domain counts do not inherit object prototype properties', async () => {
|
||||
profile('.config/chromium', 'Default', 'constructor');
|
||||
const result = await importCookies('chromium', ['constructor']);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.domainCounts.constructor).toBe(1);
|
||||
expect(JSON.stringify(result.domainCounts)).toBe('{"constructor":1}');
|
||||
});
|
||||
|
||||
test('domain matching preserves host-only boundaries and rejects malformed input', () => {
|
||||
expect(cookieDomainMatches('app.example.test', '.example.test')).toBe(true);
|
||||
expect(cookieDomainMatches('app.example.test', 'example.test')).toBe(false);
|
||||
expect(cookieDomainMatches('evil-example.test', '.example.test')).toBe(false);
|
||||
expect(normalizeCookieDomain('.EXAMPLE.TEST.')).toBe('example.test');
|
||||
expect(normalizeCookieDomain('service_name.example.test')).toBe('service_name.example.test');
|
||||
expect(normalizeCookieDomain('-service.example.test')).toBe('-service.example.test');
|
||||
expect(normalizeCookieDomain('::1')).toBe('[::1]');
|
||||
expect(normalizeCookieDomain('[0:0:0:0:0:0:0:1]')).toBe('[::1]');
|
||||
expect(cookieDomainMatches('[::1]', '[::1]')).toBe(true);
|
||||
for (const domain of ['', 'https://example.test', 'example.test/path', 'user@example.test', '..example.test', 'example.test:80', '*.example.test']) {
|
||||
expect(() => normalizeCookieDomain(domain)).toThrow(CookieImportError);
|
||||
}
|
||||
});
|
||||
|
||||
for (const domain of ['service_name.example.test', '[::1]', '-service.example.test']) {
|
||||
test(`imports the Chromium-supported hostname ${domain}`, async () => {
|
||||
profile('.config/chromium', 'Default', domain);
|
||||
const result = await importCookies('chromium', [domain]);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.cookies[0].domain).toBe(domain);
|
||||
});
|
||||
}
|
||||
|
||||
test('does not automatically retry permission denial or corrupt databases', async () => {
|
||||
for (const code of ['keychain_denied', 'keychain_timeout', 'db_corrupt']) {
|
||||
let attempts = 0;
|
||||
await expect(withCookieReadRetry(() => {
|
||||
attempts++;
|
||||
throw new CookieImportError('Safe fixture error', code, 'retry');
|
||||
})).rejects.toThrow('Safe fixture error');
|
||||
expect(attempts).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes adapter failures without exposing database error text', async () => {
|
||||
for (const [source, expected] of [['SQLITE_CORRUPT', 'db_corrupt'], ['SQLITE_READONLY', 'db_permission'], ['SQLITE_ERROR', 'db_read_error']]) {
|
||||
let caught: any;
|
||||
try { await withCookieReadRetry(() => { throw Object.assign(new Error('synthetic-private-db-detail'), { code: source }); }); }
|
||||
catch (error) { caught = error; }
|
||||
expect(caught).toBeInstanceOf(CookieImportError);
|
||||
expect(caught.code).toBe(expected);
|
||||
expect(caught.message).not.toContain('synthetic-private-db-detail');
|
||||
}
|
||||
});
|
||||
|
||||
test('actual Keychain denial is sanitized, never repeated, and clears its deadline', async () => {
|
||||
const dir = profile('Library/Application Support/Dia/User Data', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("UPDATE cookies SET value = '', encrypted_value = ?", [Buffer.from('v10synthetic-encrypted-row')]);
|
||||
db.close();
|
||||
let requests = 0;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
expect(command).toEqual(['security', 'find-generic-password', '-s', 'Dia Safe Storage', '-w']);
|
||||
requests++;
|
||||
return { stdout: new Blob([]).stream(), stderr: new Blob(['user canceled synthetic-private-detail']).stream(), exited: Promise.resolve(1), kill() {} };
|
||||
}) as typeof Bun.spawn;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockReturnValue(123 as any);
|
||||
const clear = spyOn(globalThis, 'clearTimeout').mockImplementation(() => {});
|
||||
try {
|
||||
let error: any;
|
||||
try { await withCookieReadRetry(() => importCookies('dia', ['example.test'])); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_denied');
|
||||
expect(error.message).not.toContain('synthetic-private-detail');
|
||||
expect(requests).toBe(1);
|
||||
expect(clear).toHaveBeenCalledWith(123);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
clear.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds transient database retries to three attempts', async () => {
|
||||
let attempts = 0;
|
||||
await expect(withCookieReadRetry(() => {
|
||||
attempts++;
|
||||
throw new CookieImportError('Locked', 'db_locked', 'retry');
|
||||
})).rejects.toThrow('Locked');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { sendCommand } from '../src/cli';
|
||||
|
||||
afterEach(() => mock.restore());
|
||||
|
||||
describe('cookie import CLI transport', () => {
|
||||
const state = { port: 9470, token: 'synthetic-fixture' } as any;
|
||||
|
||||
test('allows the bounded import stages to finish without changing other command budgets', async () => {
|
||||
const budgets: number[] = [];
|
||||
spyOn(AbortSignal, 'timeout').mockImplementation(ms => {
|
||||
budgets.push(ms);
|
||||
return new AbortController().signal;
|
||||
});
|
||||
spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('Synthetic receipt'));
|
||||
const output = spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await sendCommand(state, 'cookie-import-browser', ['chromium', '--domain', 'example.test']);
|
||||
await sendCommand(state, 'text', []);
|
||||
expect(budgets).toEqual([90_000, 30_000]);
|
||||
expect(output).toHaveBeenCalledWith('Synthetic receipt');
|
||||
});
|
||||
|
||||
for (const failure of ['ECONNRESET', 'ECONNREFUSED', 'AbortError', 'TimeoutError', 'fetch failed']) {
|
||||
test(`does not replay an import after ${failure}`, async () => {
|
||||
const request = spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
throw Object.assign(new Error(failure), { code: failure, name: failure });
|
||||
});
|
||||
await expect(sendCommand(state, 'cookie-import-browser', ['chromium', '--all'])).rejects.toThrow('was not replayed');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('does not automatically retry cookie import after an authorization change', async () => {
|
||||
const request = spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('Unauthorized', { status: 401 }));
|
||||
await expect(sendCommand(state, 'cookie-import-browser', ['chromium', '--all'])).rejects.toThrow('retry manually');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
|
||||
for (const sameOrigin of [false, true]) {
|
||||
test(`two real picker windows cannot reset the other ${sameOrigin ? 'same-origin tab' : 'same-host different-port origin'}`, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), 'picker-binding-'));
|
||||
const profile = path.join(home, '.config/chromium/Default');
|
||||
mkdirSync(profile, { recursive: true });
|
||||
expect(realpathSync(profile).startsWith(realpathSync(home) + path.sep)).toBe(true);
|
||||
const database = new Database(path.join(profile, 'Cookies'));
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 0, 1, 0, 1)', ['127.0.0.1', 'fixture', 'synthetic', Buffer.alloc(0), '/']);
|
||||
database.close();
|
||||
const serveTarget = () => Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
const authenticated = (request.headers.get('cookie') ?? '').split(';').some(value => value.trim() === 'fixture=synthetic');
|
||||
return new Response(authenticated ? '<div id="fixture-identity">Synthetic account</div>' : '<div>Not signed in</div>', {
|
||||
headers: { 'Content-Type': 'text/html' }, status: authenticated ? 200 : 401,
|
||||
});
|
||||
} });
|
||||
const firstServer = serveTarget();
|
||||
const secondServer = sameOrigin ? firstServer : serveTarget();
|
||||
let browser: Browser | undefined;
|
||||
let picker: ReturnType<typeof Bun.serve> | undefined;
|
||||
let homeMock: ReturnType<typeof spyOn> | undefined;
|
||||
const selector = process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
const identity = process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
process.env.GSTACK_COOKIE_AUTH_SELECTOR = '#fixture-identity';
|
||||
process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = 'Synthetic account';
|
||||
const destination = await browser.newContext();
|
||||
destination.setDefaultTimeout(5_000);
|
||||
const targetA = await destination.newPage();
|
||||
const targetB = await destination.newPage();
|
||||
await targetA.goto(`http://127.0.0.1:${firstServer.port}/a`);
|
||||
await targetB.goto(`http://127.0.0.1:${secondServer.port}/b`);
|
||||
for (const target of [targetA, targetB]) {
|
||||
await target.evaluate(() => { localStorage.setItem('keep', 'preserved'); sessionStorage.setItem('keep', 'preserved'); });
|
||||
}
|
||||
const bm = { getActiveSession: () => ({ getPage: () => targetA }), trackCookieImportDomains() {} } as any;
|
||||
picker = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: request => handleCookiePickerRoute(new URL(request.url), request, bm) });
|
||||
const pickerOrigin = `http://127.0.0.1:${picker.port}`;
|
||||
const client = await browser.newContext();
|
||||
client.setDefaultTimeout(5_000);
|
||||
const windowA = await client.newPage();
|
||||
const windowB = await client.newPage();
|
||||
const importButton = /^(?:Import|Reimport) 127\.0\.0\.1$/;
|
||||
const errors: string[] = [];
|
||||
for (const window of [windowA, windowB]) window.on('pageerror', error => errors.push(error.message));
|
||||
const open = async (window: typeof windowA, target: typeof targetA) => {
|
||||
const code = generatePickerCode({ browser: 'Chromium', target: { page: target, url: target.url() } });
|
||||
await window.goto(pickerOrigin + '/cookie-picker?code=' + code);
|
||||
await window.getByRole('button', { name: importButton }).waitFor();
|
||||
await window.locator('#clear-storage').check();
|
||||
await window.locator('#verify-auth').check();
|
||||
};
|
||||
await open(windowA, targetA);
|
||||
await open(windowB, targetB);
|
||||
await windowA.getByRole('button', { name: importButton }).click();
|
||||
await windowA.getByRole('status').filter({ hasText: 'Reopen the picker from the intended page before continuing.' }).waitFor();
|
||||
for (const target of [targetA, targetB]) {
|
||||
expect(await target.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual(['preserved', 'preserved']);
|
||||
}
|
||||
expect(await destination.cookies()).toEqual([]);
|
||||
expect(await targetA.locator('#fixture-identity').count()).toBe(0);
|
||||
expect(await targetB.locator('#fixture-identity').count()).toBe(0);
|
||||
await windowB.getByRole('button', { name: importButton }).click();
|
||||
await windowB.getByRole('status').filter({ hasText: 'Authentication verified on the captured target.' }).waitFor();
|
||||
expect(await targetB.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual([null, null]);
|
||||
expect(await targetA.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual([sameOrigin ? null : 'preserved', 'preserved']);
|
||||
expect(await targetB.locator('#fixture-identity').innerText()).toBe('Synthetic account');
|
||||
expect(await targetA.locator('#fixture-identity').count()).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
homeMock?.mockRestore();
|
||||
if (selector === undefined) delete process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
else process.env.GSTACK_COOKIE_AUTH_SELECTOR = selector;
|
||||
if (identity === undefined) delete process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
else process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = identity;
|
||||
await browser?.close();
|
||||
picker?.stop(true);
|
||||
firstServer.stop(true);
|
||||
if (!sameOrigin) secondServer.stop(true);
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_900_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 40_000);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
import * as importer from '../src/cookie-import-browser';
|
||||
import * as operation from '../src/cookie-import-operation';
|
||||
import * as auth from '../src/cookie-auth-verification';
|
||||
|
||||
const origin = 'http://127.0.0.1:9470';
|
||||
let bm: any;
|
||||
let context: any;
|
||||
let reads: ReturnType<typeof spyOn>[];
|
||||
let reset: ReturnType<typeof spyOn>;
|
||||
let verify: ReturnType<typeof spyOn>;
|
||||
let previousSelector: string | undefined;
|
||||
let previousIdentity: string | undefined;
|
||||
|
||||
function page(url: string) {
|
||||
return { url: () => url, isClosed: () => false, context: () => context } as any;
|
||||
}
|
||||
|
||||
async function request(path: string, cookie?: string, instance?: string, body?: unknown, bearer?: string) {
|
||||
const url = new URL(origin + '/cookie-picker' + path);
|
||||
return handleCookiePickerRoute(url, new Request(url, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
...(instance ? { 'X-Gstack-Picker-Instance': instance } : {}),
|
||||
...(bearer ? { Authorization: 'Bearer ' + bearer } : {}) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
}), bm, 'fixture-bearer');
|
||||
}
|
||||
|
||||
async function open(target: any) {
|
||||
const code = generatePickerCode({ target: { page: target, url: target.url() } });
|
||||
const exchanged = await request('?code=' + code);
|
||||
expect(exchanged.status).toBe(302);
|
||||
const cookie = exchanged.headers.get('set-cookie')!.split(';')[0];
|
||||
const response = await request('', cookie);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
const config = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]);
|
||||
return { cookie, config, html, code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousSelector = process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
previousIdentity = process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
process.env.GSTACK_COOKIE_AUTH_SELECTOR = '#fixture-identity';
|
||||
process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = 'Synthetic account';
|
||||
context = { addCookies: mock(async () => {}), clearCookies: mock(async () => {}),
|
||||
browser: () => ({ browserType: () => ({ name: () => 'chromium' }) }) };
|
||||
bm = { getActiveSession: () => ({ getPage: () => page('https://example.test/current') }), trackCookieImportDomains: mock(() => {}) };
|
||||
reads = [
|
||||
spyOn(importer, 'findInstalledBrowsers').mockReturnValue([{ name: 'Chromium', aliases: ['chromium'] }] as any),
|
||||
spyOn(operation, 'getCookieProfiles').mockResolvedValue({ profiles: [{ name: 'Default', displayName: 'Synthetic' }], recommendedProfile: 'Default' }),
|
||||
spyOn(importer, 'listDomains').mockReturnValue({ browser: 'Chromium', domains: [{ domain: '.example.test', count: 1 }] }),
|
||||
spyOn(importer, 'importCookies').mockResolvedValue({ cookies: [{ name: 'fixture', value: 'synthetic', domain: '.example.test', path: '/',
|
||||
expires: -1, secure: true, httpOnly: true, sameSite: 'Lax' }], count: 1, failed: 0, domainCounts: { '.example.test': 1 } }),
|
||||
];
|
||||
reset = spyOn(auth, 'clearCookieTargetStorage').mockResolvedValue(undefined);
|
||||
verify = spyOn(auth, 'verifyCookieAuthentication').mockResolvedValue({ verified: true, reason: 'verified' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
if (previousSelector === undefined) delete process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
else process.env.GSTACK_COOKIE_AUTH_SELECTOR = previousSelector;
|
||||
if (previousIdentity === undefined) delete process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
else process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = previousIdentity;
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_900_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
});
|
||||
|
||||
describe('cookie picker document binding', () => {
|
||||
test('renders independent instance identifiers without exposing authentication credentials', async () => {
|
||||
const first = await open(page('https://example.test/a'));
|
||||
const second = await open(page('https://example.test/b'));
|
||||
for (const picker of [first, second]) {
|
||||
expect(picker.config.pickerInstance).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(picker.html).not.toContain(picker.cookie.slice('gstack_picker='.length));
|
||||
expect(picker.html).not.toContain(picker.code);
|
||||
expect(picker.html).not.toContain('fixture-bearer');
|
||||
}
|
||||
expect(first.config.pickerInstance).not.toBe(second.config.pickerInstance);
|
||||
});
|
||||
|
||||
test('missing and forged instance headers fail before discovery or import', async () => {
|
||||
const picker = await open(page('https://example.test/a'));
|
||||
for (const instance of [undefined, 'forged', picker.cookie.slice('gstack_picker='.length)]) {
|
||||
for (const [path, payload] of [['/browsers'], ['/import', { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true }]] as const) {
|
||||
const response = await request(path, picker.cookie, instance, payload);
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).code).toBe('picker_changed');
|
||||
}
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(verify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a rendered instance identifier never authorizes an unauthenticated request', async () => {
|
||||
const picker = await open(page('https://example.test/a'));
|
||||
for (const cookie of [undefined, 'gstack_picker=' + picker.config.pickerInstance]) {
|
||||
const response = await request('/browsers', cookie, picker.config.pickerInstance);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ error: 'Unauthorized' });
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('bearer authorization ignores an unrelated cookie and stale picker identifier', async () => {
|
||||
const first = await open(page('https://example.test/a'));
|
||||
const second = await open(page('https://example.test/b'));
|
||||
const current = page('https://example.test/current');
|
||||
bm.getActiveSession = () => ({ getPage: () => current });
|
||||
const response = await request('/import', second.cookie, first.config.pickerInstance,
|
||||
{ browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true }, 'fixture-bearer');
|
||||
expect(response.status).toBe(200);
|
||||
expect(reset).toHaveBeenCalledWith(current, 'https://example.test');
|
||||
expect(verify).toHaveBeenCalledWith(current, { identitySelector: '#fixture-identity', expectedIdentity: 'Synthetic account' }, 'https://example.test');
|
||||
});
|
||||
|
||||
for (const [scenario, firstUrl, secondUrl] of [
|
||||
['same-host different-port origins', 'https://example.test:8443/a', 'https://example.test:9443/b'],
|
||||
['same-origin different tabs', 'https://example.test/a', 'https://example.test/b'],
|
||||
]) {
|
||||
test(`rejects an old window before reads or mutations for ${scenario}`, async () => {
|
||||
const first = await open(page(firstUrl));
|
||||
const target = page(secondUrl);
|
||||
const second = await open(target);
|
||||
const body = { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true };
|
||||
const calls: Array<[string, unknown?]> = [['/browsers'], ['/profiles?browser=Chromium'], ['/domains?browser=Chromium&profile=Default'],
|
||||
['/imported'], ['/import', body], ['/remove', { domains: ['example.test'] }]];
|
||||
for (const [path, payload] of calls) {
|
||||
const response = await request(path, second.cookie, first.config.pickerInstance, payload);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toMatchObject({ code: 'picker_changed', error: expect.stringContaining('Reopen') });
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(verify).not.toHaveBeenCalled();
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.clearCookies).not.toHaveBeenCalled();
|
||||
expect(first.config.targetOrigin).toBe(new URL(firstUrl).origin);
|
||||
for (const [path, payload] of calls) {
|
||||
expect((await request(path, second.cookie, second.config.pickerInstance, payload)).status).toBe(200);
|
||||
}
|
||||
for (const read of reads) expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(reset).toHaveBeenCalledWith(target, new URL(secondUrl).origin);
|
||||
expect(verify).toHaveBeenCalledWith(target, { identitySelector: '#fixture-identity', expectedIdentity: 'Synthetic account' }, new URL(secondUrl).origin);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(context.clearCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
|
||||
test('a same-site cross-port browser request carries the picker cookie but cannot mutate the session', async () => {
|
||||
let removed = 0;
|
||||
const observed: Array<{ origin: string | null; cookiePresent: boolean; status: number; contentType: string | null }> = [];
|
||||
const bm = { getActiveSession: () => ({ getPage: () => ({ context: () => ({ clearCookies: async () => { removed++; } }) }) }) } as any;
|
||||
const picker = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) {
|
||||
const response = await handleCookiePickerRoute(new URL(request.url), request, bm);
|
||||
if (request.method === 'POST') observed.push({ origin: request.headers.get('origin'), cookiePresent: /(?:^|;\s*)gstack_picker=/.test(request.headers.get('cookie') ?? ''), status: response.status, contentType: request.headers.get('content-type') });
|
||||
if (request.method === 'GET' && response.status === 200) {
|
||||
const config = (await response.text()).match(/<script id="picker-config" type="application\/json">.*?<\/script>/s)![0];
|
||||
return new Response('<title>Synthetic authorized picker</title>' + config, { headers: { 'Content-Type': 'text/html' } });
|
||||
}
|
||||
return response;
|
||||
} });
|
||||
const attacker = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('<title>Synthetic cross-port source</title>', { headers: { 'Content-Type': 'text/html' } }) });
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
const pickerOrigin = `http://127.0.0.1:${picker.port}`;
|
||||
const attackerOrigin = `http://127.0.0.1:${attacker.port}`;
|
||||
await page.goto(`${pickerOrigin}/cookie-picker?code=${generatePickerCode()}`);
|
||||
const sameOriginStatus = await page.evaluate(async () => (await fetch('/cookie-picker/remove', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json',
|
||||
'X-Gstack-Picker-Instance': JSON.parse(document.getElementById('picker-config')!.textContent!).pickerInstance }, body: JSON.stringify({ domains: ['synthetic.test'] }),
|
||||
})).status);
|
||||
expect(sameOriginStatus).toBe(200);
|
||||
expect(removed).toBe(1);
|
||||
observed.length = 0;
|
||||
await page.goto(attackerOrigin);
|
||||
for (const action of ['import', 'remove']) {
|
||||
await page.evaluate(async ({ target, action }) => {
|
||||
await fetch(`${target}/cookie-picker/${action}`, { method: 'POST', mode: 'no-cors', credentials: 'include',
|
||||
headers: { 'Content-Type': 'text/plain' }, body: JSON.stringify({ browser: 'Chromium', domains: ['synthetic.test'], clearStorage: true }) });
|
||||
}, { target: pickerOrigin, action });
|
||||
}
|
||||
expect(observed).toEqual([1, 2].map(() => ({ origin: attackerOrigin, cookiePresent: true, status: 403, contentType: 'text/plain' })));
|
||||
expect(removed).toBe(1);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
picker.stop(true);
|
||||
attacker.stop(true);
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_600_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
}
|
||||
}, 40_000);
|
||||
@@ -378,16 +378,20 @@ describe('cookie-picker-routes', () => {
|
||||
|
||||
expect(html).not.toContain(authToken);
|
||||
expect(html).not.toContain('AUTH_TOKEN');
|
||||
expect(html).not.toContain(session);
|
||||
});
|
||||
|
||||
test('data routes accept session cookie', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const session = await getSessionCookie(bm, 'test-token');
|
||||
const document = await handleCookiePickerRoute(makeUrl('/cookie-picker'), makeReq('GET', undefined, { Cookie: `gstack_picker=${session}` }), bm, 'test-token');
|
||||
const html = await document.text();
|
||||
const { pickerInstance } = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]);
|
||||
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
headers: { 'Cookie': `gstack_picker=${session}`, 'X-Gstack-Picker-Instance': pickerInstance },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { getCookiePickerHTML } from '../src/cookie-picker-ui';
|
||||
|
||||
type PickerOptions = NonNullable<Parameters<typeof getCookiePickerHTML>[1]>;
|
||||
type ApiRequest = { path: string; method: string; browser: string | null; profile: string | null; pickerInstance: string | null; body?: any };
|
||||
|
||||
describe('rendered cookie picker', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let origin: string;
|
||||
let options: PickerOptions;
|
||||
let requests: ApiRequest[];
|
||||
let handler: (request: ApiRequest) => Response | Promise<Response> | undefined;
|
||||
let profiles: Record<string, { profiles: { name: string; displayName: string; unavailable?: boolean }[]; recommendedProfile?: string }>;
|
||||
let browsers: { name: string; aliases?: string[] }[];
|
||||
let imported: { domain: string; count: number }[];
|
||||
let errors: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/cookie-picker') return new Response(getCookiePickerHTML(server.port!, options), { headers: { 'Content-Type': 'text/html' } });
|
||||
if (!url.pathname.startsWith('/cookie-picker/')) return new Response(null, { status: 204 });
|
||||
const record = { path: url.pathname.slice('/cookie-picker'.length), method: request.method,
|
||||
browser: url.searchParams.get('browser'), profile: url.searchParams.get('profile'),
|
||||
pickerInstance: request.headers.get('X-Gstack-Picker-Instance'),
|
||||
...(request.method === 'POST' ? { body: await request.json() } : {}) };
|
||||
requests.push(record);
|
||||
const response = handler(record);
|
||||
if (response) return response;
|
||||
if (record.path === '/browsers') return Response.json({ browsers });
|
||||
if (record.path === '/imported') return Response.json({ domains: imported });
|
||||
if (record.path === '/profiles') return Response.json(profiles[record.browser!] || { profiles: [] });
|
||||
if (record.path === '/domains') return Response.json({ domains: [{ domain: '.' + record.browser!.toLowerCase() + '-' + record.profile!.toLowerCase().replaceAll(' ', '-') + '.test', count: 4 }] });
|
||||
if (record.path === '/import') return Response.json({ browser: record.body.browser, profile: record.body.profile,
|
||||
imported: 2, failed: 0, domainCounts: Object.fromEntries(record.body.domains.map((domain: string) => [domain, 2])),
|
||||
failureReasons: {}, outcome: 'imported', message: 'Cookies copied.', reset: 'not_requested',
|
||||
verification: { verified: false, reason: 'not_requested' } });
|
||||
if (record.path === '/remove') return Response.json({ removed: record.body.domains.length });
|
||||
return Response.json({ error: 'Fixture route unavailable' }, { status: 404 });
|
||||
} });
|
||||
origin = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
options = {};
|
||||
requests = [];
|
||||
imported = [];
|
||||
handler = () => undefined;
|
||||
browsers = [{ name: 'Chrome', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'] }, { name: 'Dia', aliases: ['dia'] }];
|
||||
profiles = {
|
||||
Chrome: { profiles: [{ name: 'Default', displayName: 'Personal' }, { name: 'Profile 1', displayName: 'Personal' }], recommendedProfile: 'Default' },
|
||||
Dia: { profiles: [{ name: 'Profile 1', displayName: 'Work' }, { name: 'Profile 2', displayName: 'Work' }], recommendedProfile: 'Profile 2' },
|
||||
};
|
||||
errors = [];
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.close();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
server?.stop(true);
|
||||
});
|
||||
|
||||
async function openPicker() {
|
||||
await page.goto(`${origin}/cookie-picker`);
|
||||
await page.locator('.pill').first().waitFor();
|
||||
}
|
||||
|
||||
test('sends the rendered instance on every discovery and mutation request', async () => {
|
||||
options = { pickerInstance: 'synthetic-picker-instance' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Remove .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Removed imported cookies' }).waitFor();
|
||||
expect([...new Set(requests.map(request => request.path))].sort()).toEqual(['/browsers', '/domains', '/import', '/imported', '/profiles', '/remove']);
|
||||
expect(requests.every(request => request.pickerInstance === options.pickerInstance)).toBe(true);
|
||||
});
|
||||
|
||||
test('a stale picker fails with actionable reopen guidance instead of a success receipt', async () => {
|
||||
options = { pickerInstance: 'stale-picker-instance' };
|
||||
handler = request => request.path === '/import'
|
||||
? Response.json({ code: 'picker_changed', error: 'Reopen the picker.' }, { status: 403 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Reopen the picker from the intended page before continuing.' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication was not verified.');
|
||||
expect(await page.locator('#imported-domains').textContent()).toContain('No cookies imported yet');
|
||||
});
|
||||
|
||||
test('preserves default storage and leaves verification disabled without a bound target', async () => {
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).waitFor();
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
expect(await page.locator(selector).isDisabled()).toBe(true);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body).toEqual({ browser: 'Chrome', profile: 'Default',
|
||||
domains: ['.chrome-default.test'], clearStorage: false, verifyAuth: false });
|
||||
expect(await page.getByRole('status').textContent()).toContain('Storage preserved. Authentication not checked.');
|
||||
expect(await page.getByRole('status').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not enable verification or storage reset merely because a target and assertion exist', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#target-origin').textContent()).toBe('https://target.test');
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
expect(await page.locator(selector).isEnabled()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('prechecks only explicitly requested options and supports keyboard changes', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true, clearStorage: true, verifyAuth: true };
|
||||
await openPicker();
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(true);
|
||||
await page.locator(selector).focus();
|
||||
await page.keyboard.press('Space');
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
}
|
||||
const add = page.getByRole('button', { name: 'Import .chrome-default.test', exact: true });
|
||||
await add.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body.verifyAuth).toBe(false);
|
||||
expect(requests.find(request => request.path === '/import')?.body.clearStorage).toBe(false);
|
||||
expect(await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).evaluate(element => element === document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
for (const targetOrigin of [undefined, 'about:blank', 'javascript:alert(1)']) {
|
||||
test(`disables explicit mutation options for an unavailable target ${String(targetOrigin).split(':')[0]}`, async () => {
|
||||
options = { targetOrigin, clearStorage: true, verifyAuth: true, verificationAvailable: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#clear-storage').isChecked()).toBe(false);
|
||||
expect(await page.locator('#verify-auth').isChecked()).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#verify-auth').isDisabled()).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('disables verification without configuration even when requested', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verifyAuth: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#verify-auth').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#verify-auth').isChecked()).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps reset disabled on unsupported targets without disabling import', async () => {
|
||||
options = { targetOrigin: 'https://target.test', clearStorage: true, storageResetAvailable: false };
|
||||
await openPicker();
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#clear-storage').isChecked()).toBe(false);
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body.clearStorage).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('requires explicit selection among ambiguous profiles and shows directory discriminators', async () => {
|
||||
delete profiles.Chrome.recommendedProfile;
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'No unambiguous profile' }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').count()).toBe(0);
|
||||
expect(await page.locator('.profile-pill').allTextContents()).toEqual(['Personal (Default)', 'Personal (Profile 1)']);
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
expect(await page.locator('#btn-import-all').isVisible()).toBe(false);
|
||||
await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
test('does not guess a sole profile when the server cannot recommend it', async () => {
|
||||
profiles.Chrome = { profiles: [{ name: 'Default', displayName: 'Personal', unavailable: true }] };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'No unambiguous profile' }).waitFor();
|
||||
expect(await page.locator('.profile-pill').textContent()).toContain('could not inspect');
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('uses the explicit profile only for its matching browser before painting the active pill', async () => {
|
||||
options = { browser: 'chrome', profile: 'Profile 1' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('recognizes supported browser aliases and binds the explicit profile to the canonical browser', async () => {
|
||||
options = { browser: 'GOOGLE-CHROME', profile: 'Profile 1' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Chrome');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
await page.getByRole('button', { name: 'Chrome', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/profiles').map(request => request.browser)).toEqual(['Chrome', 'Dia', 'Chrome']);
|
||||
});
|
||||
|
||||
test('does not interpret a browser alias substring as an installed browser', async () => {
|
||||
options = { browser: 'google' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested browser is unavailable' }).waitFor();
|
||||
expect(await page.locator('.pill.active').count()).toBe(0);
|
||||
expect(requests.filter(request => request.path === '/profiles')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not silently replace a missing explicit profile or browser', async () => {
|
||||
options = { browser: 'Chrome', profile: 'Missing' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested profile is unavailable' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
options = { browser: 'Missing browser', profile: 'Default' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested browser is unavailable' }).waitFor();
|
||||
expect(await page.locator('.pill.active').count()).toBe(0);
|
||||
});
|
||||
|
||||
for (const earlierFirst of [true, false]) {
|
||||
test(`ignores stale profile responses when the earlier request returns ${earlierFirst ? 'first' : 'last'}`, async () => {
|
||||
const chrome = Promise.withResolvers<Response>();
|
||||
const dia = Promise.withResolvers<Response>();
|
||||
const chromeSeen = Promise.withResolvers<void>();
|
||||
const diaSeen = Promise.withResolvers<void>();
|
||||
handler = request => {
|
||||
if (request.path !== '/profiles') return;
|
||||
if (request.browser === 'Chrome') { chromeSeen.resolve(); return chrome.promise; }
|
||||
diaSeen.resolve(); return dia.promise;
|
||||
};
|
||||
await openPicker();
|
||||
await chromeSeen.promise;
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await diaSeen.promise;
|
||||
if (earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome'));
|
||||
chrome.resolve(Response.json(profiles.Chrome));
|
||||
await response;
|
||||
expect(await page.locator('.profile-pill.active').count()).toBe(0);
|
||||
}
|
||||
dia.resolve(Response.json(profiles.Dia));
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
if (!earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome'));
|
||||
chrome.resolve(Response.json(profiles.Chrome));
|
||||
await response;
|
||||
}
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Dia');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
expect(requests.filter(request => request.path === '/domains').map(request => request.browser)).toEqual(['Dia']);
|
||||
});
|
||||
}
|
||||
|
||||
for (const earlierFirst of [true, false]) {
|
||||
test(`ignores stale domain responses when the earlier request returns ${earlierFirst ? 'first' : 'last'}`, async () => {
|
||||
const first = Promise.withResolvers<Response>();
|
||||
const second = Promise.withResolvers<Response>();
|
||||
const firstSeen = Promise.withResolvers<void>();
|
||||
const secondSeen = Promise.withResolvers<void>();
|
||||
handler = request => {
|
||||
if (request.path !== '/domains') return;
|
||||
if (request.profile === 'Default') { firstSeen.resolve(); return first.promise; }
|
||||
secondSeen.resolve(); return second.promise;
|
||||
};
|
||||
await openPicker();
|
||||
await firstSeen.promise;
|
||||
await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).click();
|
||||
await secondSeen.promise;
|
||||
if (earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('&profile=Default'));
|
||||
first.resolve(Response.json({ domains: [{ domain: '.stale.test', count: 4 }] }));
|
||||
await response;
|
||||
expect(await page.locator('.btn-add').count()).toBe(0);
|
||||
}
|
||||
second.resolve(Response.json({ domains: [{ domain: '.current.test', count: 2 }] }));
|
||||
await page.getByRole('button', { name: 'Import .current.test', exact: true }).waitFor();
|
||||
if (!earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('&profile=Default'));
|
||||
first.resolve(Response.json({ domains: [{ domain: '.stale.test', count: 4 }] }));
|
||||
await response;
|
||||
}
|
||||
expect(await page.locator('#source-domains .domain-name').allTextContents()).toEqual(['.current.test']);
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ['partial', 'empty', 'failed']) {
|
||||
test(`reports an HTTP-200 ${outcome} receipt persistently rather than as silent success`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: outcome === 'partial' ? 2 : 0, failed: 3,
|
||||
domainCounts: outcome === 'partial' ? { '.chrome-default.test': 2 } : {}, failureReasons: { unsupported_encryption: 3 },
|
||||
outcome, message: 'Synthetic import receipt.', reset: outcome === 'failed' ? 'failed' : 'not_requested', verification: { verified: false, reason: 'not_requested' } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Synthetic import receipt.' }).waitFor();
|
||||
const status = await page.getByRole('status').textContent();
|
||||
expect(status).toContain(outcome === 'partial' ? 'Partial import.' : outcome === 'empty' ? 'No cookies imported.' : 'Import failed.');
|
||||
expect(status).toContain('3 failed.');
|
||||
expect(status).toContain('unsupported encryption: 3.');
|
||||
expect(status).toContain('Authentication not checked.');
|
||||
expect(await page.getByRole('status').getAttribute('class')).toContain(outcome === 'failed' ? 'error' : 'warning');
|
||||
expect(await page.locator('.btn-add.imported').count()).toBe(0);
|
||||
expect(await page.locator('#imported-domains .domain-name').count()).toBe(outcome === 'partial' ? 1 : 0);
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('sets imported counts on explicit reimport rather than adding them again', async () => {
|
||||
imported = [{ domain: '.chrome-default.test', count: 6 }];
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('2');
|
||||
await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('2');
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('serializes imports, disables source switches, and does not repeat POSTs on double submit', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
imported = [{ domain: '.already.test', count: 1 }];
|
||||
const result = Promise.withResolvers<Response>();
|
||||
const seen = Promise.withResolvers<void>();
|
||||
handler = request => { if (request.path === '/import') { seen.resolve(); return result.promise; } };
|
||||
await openPicker();
|
||||
const add = page.getByRole('button', { name: 'Import .chrome-default.test', exact: true });
|
||||
await add.evaluate((element: HTMLButtonElement) => { element.click(); element.click(); });
|
||||
await seen.promise;
|
||||
expect(await page.getByRole('button', { name: 'Dia', exact: true }).isDisabled()).toBe(true);
|
||||
expect(await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).isDisabled()).toBe(true);
|
||||
for (const selector of ['#btn-import-all', '#clear-storage', '#verify-auth', '#search', '.btn-trash']) expect(await page.locator(selector).isDisabled()).toBe(true);
|
||||
await page.locator('#btn-import-all').evaluate((element: HTMLButtonElement) => element.click());
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).evaluate((element: HTMLButtonElement) => element.click());
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
result.resolve(Response.json({ imported: 1, failed: 0, domainCounts: { '.chrome-default.test': 1 }, outcome: 'imported', reset: 'not_requested', verification: { verified: false, reason: 'not_requested' } }));
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Chrome');
|
||||
expect(await page.getByRole('button', { name: 'Dia', exact: true }).isEnabled()).toBe(true);
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('serializes removal with imports and retains counts on a failed removal', async () => {
|
||||
imported = [{ domain: '.already.test', count: 1 }];
|
||||
const result = Promise.withResolvers<Response>();
|
||||
const seen = Promise.withResolvers<void>();
|
||||
handler = request => { if (request.path === '/remove') { seen.resolve(); return result.promise; } };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Remove .already.test', exact: true }).click();
|
||||
await seen.promise;
|
||||
expect(await page.locator('.btn-add').isDisabled()).toBe(true);
|
||||
await page.locator('.btn-add').evaluate((element: HTMLButtonElement) => element.click());
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(0);
|
||||
result.resolve(Response.json({ error: 'synthetic-private-error' }, { status: 500 }));
|
||||
await page.getByRole('status').filter({ hasText: 'Cookie removal did not complete.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('1');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === '/remove')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('reports mutation errors without replaying the POST or exposing raw errors', async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ error: 'synthetic-private-error', action: 'retry' }, { status: 503 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(await page.locator('.btn-add').isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
for (const [code, guidance] of [
|
||||
['keychain_denied', 'Allow access in the OS permission prompt or settings'],
|
||||
['keychain_timeout', 'Check for a pending OS permission prompt'],
|
||||
['keychain_error', 'Check the OS credential store'],
|
||||
['db_locked', 'Close the source browser, then retry manually'],
|
||||
['db_corrupt', 'Choose another profile or sign in manually'],
|
||||
['db_permission', 'Check source-profile permissions'],
|
||||
['db_read_error', 'Cookie data could not be read from this profile'],
|
||||
['sqlite_unavailable', 'Node.js 22.13 or newer with built-in SQLite enabled'],
|
||||
['storage_reset_unsupported', 'Storage reset requires a Chromium target'],
|
||||
['profile_required', 'Choose a source profile explicitly'],
|
||||
['target_changed', 'Reopen the picker from the intended HTTP(S) page'],
|
||||
['target_closed', 'The captured target is closed'],
|
||||
['target_mismatch', 'Select its cookie domain or reopen the picker'],
|
||||
['not_supported', 'Native cookie import is unsupported'],
|
||||
['native_profile_unsupported', 'This browser profile does not support native cookie extraction'],
|
||||
['native_unqualified', 'process ownership and cleanup are not qualified'],
|
||||
['native_cleanup_failed', 'Inspect the source browser before any manual retry'],
|
||||
['native_supervision_failed', 'Native browser supervision could not start'],
|
||||
['native_timeout', 'Native cookie extraction timed out'],
|
||||
['browser_running', 'Close it yourself before retrying'],
|
||||
]) {
|
||||
test(`shows actionable allowlisted ${code} guidance without server prose or automatic replay`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code, action: 'retry',
|
||||
error: '<img src=x onerror="window.errorXss=1"> synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain(guidance);
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).errorXss)).toBeUndefined();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(await page.locator('.btn-add').isEnabled()).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const code of ['unknown_private_code', '__proto__', '<img src=x onerror="window.errorXss=1">']) {
|
||||
test(`uses safe generic guidance for an unallowlisted error code ${code.startsWith('<') ? 'markup' : code}`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code, error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Inspect the source and destination before retrying');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain(code);
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const path of ['/profiles', '/domains']) {
|
||||
test(`preserves safe actionable guidance on ${path} read failures`, async () => {
|
||||
handler = request => request.path === path ? Response.json({ code: 'db_locked', error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'source cookie database is busy' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Close the source browser, then retry manually');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === path)).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('permits a deliberate manual retry after permission recovery without replaying automatically', async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code: 'keychain_denied', action: 'retry', error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Keychain access was denied' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
handler = () => undefined;
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('imports the visible filtered domains in one request with explicit target options', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
handler = request => request.path === '/domains' ? Response.json({ domains: [{ domain: '.one.test', count: 3 }, { domain: '.two.test', count: 2 }, { domain: '.other.invalid', count: 1 }] }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .one.test', exact: true }).waitFor();
|
||||
await page.getByRole('textbox', { name: 'Search cookie domains' }).fill('.test');
|
||||
await page.locator('#clear-storage').check();
|
||||
await page.locator('#verify-auth').check();
|
||||
await page.getByRole('button', { name: 'Import All (2)', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(requests.find(request => request.path === '/import')?.body).toEqual({ browser: 'Chrome', profile: 'Default', domains: ['.one.test', '.two.test'], clearStorage: true, verifyAuth: true });
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication not verified');
|
||||
});
|
||||
|
||||
for (const importedCount of [0, 2]) {
|
||||
test(`requires a requested positive check and nonzero import before verified (${importedCount} imported)`, async () => {
|
||||
options = { targetOrigin: 'https://target.test', verifyAuth: true, verificationAvailable: true, clearStorage: true };
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: importedCount, failed: 0, domainCounts: {}, outcome: importedCount ? 'imported' : 'empty', reset: 'cleared', verification: { verified: true, reason: 'verified' } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Storage cleared for' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain(importedCount ? 'Authentication verified on the captured target.' : 'Authentication not verified.');
|
||||
});
|
||||
}
|
||||
|
||||
test('does not claim verification for an unrequested check even if a response says verified', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: 2, failed: 0, domainCounts: {}, outcome: 'imported', verification: { verified: true } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication not checked.');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('Authentication verified');
|
||||
});
|
||||
|
||||
test('escapes script configuration, profile attributes, domain attributes, and status text', async () => {
|
||||
const payload = '\"\'><img src=x onerror="window.fixtureXss=1"></script><script>window.fixtureXss=1</script>';
|
||||
options = { browser: payload, profile: payload, targetOrigin: 'https://target.test' };
|
||||
browsers = [{ name: payload }];
|
||||
profiles = { [payload]: { profiles: [{ name: payload, displayName: payload }] } };
|
||||
handler = request => request.path === '/domains' ? Response.json({ domains: [{ domain: payload, count: 1 }] })
|
||||
: request.path === '/import' ? Response.json({ imported: 1, failed: 0, domainCounts: { [payload]: 1 }, outcome: 'imported', message: payload }) : undefined;
|
||||
await openPicker();
|
||||
await page.locator('.btn-add').waitFor();
|
||||
expect(await page.locator('.profile-pill').getAttribute('data-profile')).toBe(payload);
|
||||
expect(await page.locator('.btn-add').getAttribute('data-domain')).toBe(payload);
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).fixtureXss)).toBeUndefined();
|
||||
await page.locator('.btn-add').click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).fixtureXss)).toBeUndefined();
|
||||
expect(requests.find(request => request.path === '/import')?.body.profile).toBe(payload);
|
||||
expect(await page.locator('.btn-trash').getAttribute('data-domain')).toBe(payload);
|
||||
});
|
||||
|
||||
test('serializes only a safe target origin, never credentials, path, or query data', async () => {
|
||||
const target = new URL('https://target.test/private-path?fixture-token=private-value');
|
||||
target.username = 'fixture-user';
|
||||
target.password = 'fixture';
|
||||
options = { targetOrigin: target.href };
|
||||
const html = getCookiePickerHTML(server.port!, options);
|
||||
expect(html).not.toContain('fixture-user');
|
||||
expect(html).not.toContain('private-path');
|
||||
expect(html).not.toContain('private-value');
|
||||
await openPicker();
|
||||
expect(await page.locator('#target-origin').textContent()).toBe('https://target.test');
|
||||
expect(await page.getByRole('status').getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseGuiReadiness, runGuiReadiness, validateGuiReadinessAuthority } from '../../.github/scripts/qualify-dia-macos';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dia-gui-readiness-test-')));
|
||||
const source = path.resolve(import.meta.dir, '../../.github/scripts/dia-gui-readiness.c');
|
||||
const executable = path.join(root, 'metadata-probe');
|
||||
const names = ['chrome', 'chromium', 'arc', 'dia', 'comet', 'brave', 'edge', 'safari', 'cookies'];
|
||||
const observation = () => ({ protocol: 1, supported: true, identity: { effectiveUidMatches: true, homeMatchesRegistered: true },
|
||||
security: { status: 0, graphicAccess: true, rootSession: false, tty: false, remote: false },
|
||||
quartz: { present: true, sameUid: true, loginDone: true, onConsole: true }, browserRoots: null as any });
|
||||
|
||||
beforeAll(() => {
|
||||
const wrapper = path.join(root, 'metadata-probe.c');
|
||||
writeFileSync(wrapper, `#define main native_readiness_main\n#include ${JSON.stringify(source)}\n#undef main
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) return 2;
|
||||
int home = open(argv[1], O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK);
|
||||
if (home < 0) return 2;
|
||||
print_browser_roots(home, getuid()); close(home); printf("\\n"); return 0;
|
||||
}`);
|
||||
const compiler = Bun.which('clang');
|
||||
if (!compiler) throw new Error('clang is required for the POSIX metadata regression');
|
||||
const compiled = spawnSync(compiler, ['-std=c11', '-O2', '-Wall', '-Wextra', wrapper,
|
||||
...(process.platform === 'darwin' ? ['-framework', 'Security', '-framework', 'ApplicationServices'] : []), '-o', executable],
|
||||
{ encoding: 'utf8', timeout: 30_000 });
|
||||
if (compiled.status !== 0) throw new Error('metadata_fixture_compile_failed');
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const inspect = (home: string) => {
|
||||
const result = spawnSync(executable, [home], { encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
|
||||
describe('read-only GUI readiness', () => {
|
||||
test('metadata inspection reports missing fixed roots without creating state', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'empty-')));
|
||||
const before = readdirSync(home);
|
||||
const result = inspect(home);
|
||||
expect(Object.keys(result)).toEqual(names);
|
||||
for (const name of names) expect(result[name]).toEqual({ state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false });
|
||||
expect(readdirSync(home)).toEqual(before);
|
||||
expect(lstatSync(home).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('metadata inspection never follows ancestor or target symlinks', () => {
|
||||
const outside = realpathSync(mkdtempSync(path.join(root, 'other-')));
|
||||
writeFileSync(path.join(outside, 'private-sentinel'), 'unchanged fixture contents');
|
||||
const linked = realpathSync(mkdtempSync(path.join(root, 'linked-')));
|
||||
symlinkSync(outside, path.join(linked, 'Library'), 'dir');
|
||||
const blocked = inspect(linked);
|
||||
for (const name of names) expect(blocked[name]).toEqual({ state: 'unavailable', kind: 'symlink', ownerMatches: true, ancestorBlocked: true });
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'target-')));
|
||||
mkdirSync(path.join(home, 'Library'));
|
||||
symlinkSync(outside, path.join(home, 'Library/Safari'), 'dir');
|
||||
expect(inspect(home).safari).toEqual({ state: 'present', kind: 'symlink', ownerMatches: true, ancestorBlocked: false });
|
||||
expect(readdirSync(outside)).toEqual(['private-sentinel']);
|
||||
expect(readFileSync(path.join(outside, 'private-sentinel'), 'utf8')).toBe('unchanged fixture contents');
|
||||
});
|
||||
|
||||
test('wrong ancestor types and access failures are unavailable, never absent', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'wrong-type-')));
|
||||
writeFileSync(path.join(home, 'Library'), 'not a directory');
|
||||
expect(inspect(home).dia).toEqual({ state: 'unavailable', kind: 'file', ownerMatches: true, ancestorBlocked: true });
|
||||
const denied = realpathSync(mkdtempSync(path.join(root, 'denied-')));
|
||||
const library = path.join(denied, 'Library');
|
||||
mkdirSync(library, { mode: 0o700 });
|
||||
chmodSync(library, 0);
|
||||
try {
|
||||
expect(inspect(denied).dia).toEqual({ state: 'unavailable', kind: 'directory', ownerMatches: true, ancestorBlocked: true });
|
||||
expect(lstatSync(library).mode & 0o777).toBe(0);
|
||||
} finally { chmodSync(library, 0o700); }
|
||||
});
|
||||
|
||||
test('present root metadata does not open contents or change them', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'present-')));
|
||||
const safari = path.join(home, 'Library/Safari');
|
||||
mkdirSync(safari, { recursive: true });
|
||||
const file = path.join(safari, 'private-sentinel');
|
||||
writeFileSync(file, 'private fixture content');
|
||||
chmodSync(file, 0);
|
||||
const before = lstatSync(file);
|
||||
const result = inspect(home);
|
||||
expect(result.safari).toEqual({ state: 'present', kind: 'directory', ownerMatches: true, ancestorBlocked: false });
|
||||
expect(lstatSync(file).mode).toBe(before.mode);
|
||||
expect(lstatSync(file).mtimeMs).toBe(before.mtimeMs);
|
||||
expect(JSON.stringify(result)).not.toContain('private');
|
||||
chmodSync(file, 0o600);
|
||||
expect(readFileSync(file, 'utf8')).toBe('private fixture content');
|
||||
});
|
||||
|
||||
test('GUI usability requires both the security capability and the caller-owned logged-in Quartz session', () => {
|
||||
const original = observation();
|
||||
const before = JSON.stringify(original);
|
||||
Object.freeze(original.identity); Object.freeze(original.security); Object.freeze(original.quartz); Object.freeze(original);
|
||||
expect(parseGuiReadiness(original, false).usableGui).toBe(true);
|
||||
expect(JSON.stringify(original)).toBe(before);
|
||||
for (const changed of [
|
||||
{ ...observation(), security: { ...observation().security, graphicAccess: false } },
|
||||
{ ...observation(), quartz: { present: false, sameUid: null, loginDone: null, onConsole: null } },
|
||||
{ ...observation(), quartz: { present: true, sameUid: false, loginDone: null, onConsole: null } },
|
||||
{ ...observation(), quartz: { ...observation().quartz, loginDone: false } },
|
||||
{ ...observation(), identity: { effectiveUidMatches: true, homeMatchesRegistered: false } },
|
||||
{ ...observation(), security: { status: -60500, graphicAccess: null, rootSession: null, tty: null, remote: null } },
|
||||
]) expect(parseGuiReadiness(changed, false).usableGui).toBe(false);
|
||||
});
|
||||
|
||||
test('schema rejects extra identity text, cross-UID session details and false absence claims', () => {
|
||||
for (const changed of [
|
||||
{ ...observation(), username: 'private-name' },
|
||||
{ ...observation(), quartz: { ...observation().quartz, uid: 501 } },
|
||||
{ ...observation(), quartz: { present: true, sameUid: false, loginDone: true, onConsole: true } },
|
||||
{ ...observation(), security: { ...observation().security, status: -1 } },
|
||||
{ ...observation(), security: { ...observation().security, graphicAccess: 1 } },
|
||||
]) expect(() => parseGuiReadiness(changed, false)).toThrow('invalid_gui_readiness_receipt');
|
||||
const roots = Object.fromEntries(names.map(name => [name, { state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false }]));
|
||||
expect(parseGuiReadiness({ ...observation(), browserRoots: roots }, true).browserRoots).toEqual(roots);
|
||||
expect(() => parseGuiReadiness({ ...observation(), browserRoots: roots }, false)).toThrow('invalid_gui_readiness_receipt');
|
||||
expect(() => parseGuiReadiness({ ...observation(), browserRoots: { ...roots, dia: { state: 'absent', kind: 'symlink', ownerMatches: true, ancestorBlocked: true } } }, true))
|
||||
.toThrow('invalid_gui_readiness_receipt');
|
||||
});
|
||||
|
||||
test('GUI authority cannot carry browser/comparison authority or placeholder destination hashes', () => {
|
||||
const account: any = { work: '/private/tmp/dn-fixture', guiReadiness: { mode: 'gui-readiness-only', executable: '/private/tmp/dn-fixture/bin/gui-readiness',
|
||||
executableSha256: 'a'.repeat(64), sourceSha256: 'b'.repeat(64) } };
|
||||
expect(() => validateGuiReadinessAuthority(account, 'coordinator')).not.toThrow();
|
||||
for (const changed of [{ ...account, launchComparison: {} }, { ...account, destinationExecutable: '' }, { ...account, destinationSha256: 'a'.repeat(64) },
|
||||
{ ...account, guiReadiness: null }, { ...account, guiReadiness: { ...account.guiReadiness, executableSha256: ['a'.repeat(64)] } },
|
||||
{ ...account, guiReadiness: { ...account.guiReadiness, executable: '/unowned/probe' } }]) {
|
||||
expect(() => validateGuiReadinessAuthority(changed, 'coordinator')).toThrow('gui_readiness_authority_invalid');
|
||||
}
|
||||
expect(() => validateGuiReadinessAuthority(account, 'comparison-driver')).toThrow('gui_readiness_authority_invalid');
|
||||
});
|
||||
|
||||
test('the registered probe validates hashes, bounds execution, and discards unrecognized output', async () => {
|
||||
const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const exeHash = hash(executable);
|
||||
const sourceHash = hash(source);
|
||||
const input = observation();
|
||||
const execute = ((command: string, args: string[], options: any) => {
|
||||
expect(command).toBe(executable); expect(args).toEqual([]);
|
||||
expect(options.timeout).toBeGreaterThan(0); expect(options.timeout).toBeLessThanOrEqual(5000);
|
||||
expect(options.killSignal).toBe('SIGKILL'); expect(options.maxBuffer).toBe(16 * 1024);
|
||||
return { status: 0, stdout: JSON.stringify(input), stderr: 'private diagnostic text' };
|
||||
}) as typeof spawnSync;
|
||||
const result = await runGuiReadiness(executable, exeHash, source, sourceHash, false, { HOME: root }, 5000, execute);
|
||||
expect(result.available).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toContain('private diagnostic');
|
||||
await expect(runGuiReadiness(executable, 'c'.repeat(64), source, sourceHash, false, {}, 5000, execute)).rejects.toThrow('gui_readiness_inputs_changed');
|
||||
const malformed = (() => ({ status: 0, stdout: JSON.stringify({ ...input, username: 'private-name' }), stderr: '' })) as typeof spawnSync;
|
||||
expect((await runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, 5000, malformed)).available).toBe(false);
|
||||
for (const timeout of [0, NaN, Infinity]) await expect(runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, timeout, execute)).rejects.toThrow('gui_readiness_budget_exhausted');
|
||||
});
|
||||
|
||||
test('the readiness launcher loads without node_modules or Playwright and rejects conflicting CLI modes', () => {
|
||||
const directory = path.join(root, 'no-dependencies/.github/scripts');
|
||||
mkdirSync(directory, { recursive: true });
|
||||
for (const file of ['run-dia-native-qualification.ts', 'qualify-dia-macos.ts']) copyFileSync(path.resolve(import.meta.dir, '../../.github/scripts', file), path.join(directory, file));
|
||||
for (const args of [['--gui-readiness-only'], ['--gui-readiness-only', '--launch-comparison', 'node']]) {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', path.join(directory, 'run-dia-native-qualification.ts'), ...args],
|
||||
{ cwd: root, env: { HOME: root, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(2); expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout).reason).toBe('fresh_account_launcher_preflight_failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { compareDiaLaunchReceipts, normalizedLaunchHashes } from '../../.github/scripts/dia-launch-driver.mjs';
|
||||
import { runDiaLaunchComparison, safeDiaComparisonResponse } from '../../.github/scripts/qualify-dia-macos';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dc-')));
|
||||
const driverFile = path.resolve(import.meta.dir, '../../.github/scripts/dia-launch-driver.mjs');
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('diagnostic-only Dia runtime comparison', () => {
|
||||
test('normalization replaces only declared owned path boundaries', () => {
|
||||
const first = normalizedLaunchHashes(['--user-data-dir=/owned/a/home/profile', '--headless'], { HOME: '/owned/a/home', PATH: '/usr/bin:/bin' }, { home: '/owned/a/home' });
|
||||
const second = normalizedLaunchHashes(['--user-data-dir=/owned/b/home/profile', '--headless'], { HOME: '/owned/b/home', PATH: '/usr/bin:/bin' }, { home: '/owned/b/home' });
|
||||
expect(first).toEqual(second);
|
||||
expect(normalizedLaunchHashes(['--user-data-dir=/owned/a/home-other'], {}, { home: '/owned/a/home' }).argvSha256)
|
||||
.not.toBe(normalizedLaunchHashes(['--user-data-dir=/owned/b/home-other'], {}, { home: '/owned/b/home' }).argvSha256);
|
||||
expect(normalizedLaunchHashes(['--label=https://private.invalid/owned/a/home'], {}, { home: '/owned/a/home' }).argvSha256)
|
||||
.not.toBe(normalizedLaunchHashes(['--label=https://private.invalid/owned/b/home'], {}, { home: '/owned/b/home' }).argvSha256);
|
||||
expect(JSON.stringify(first)).not.toContain('/owned');
|
||||
});
|
||||
|
||||
test('IPC rejects raw payloads at any nesting depth', () => {
|
||||
for (const value of [{ args: ['private-argv'] }, { cleanup: { raw: 'private-value' } }, { error: 'private-error-value' },
|
||||
{ startupPages: { categories: ['https://private.invalid'] } }, { driver: { release: '/private/path' } }]) {
|
||||
expect(safeDiaComparisonResponse(value)).toBe(false);
|
||||
}
|
||||
expect(safeDiaComparisonResponse({ protocol: 1, purpose: 'source', error: 'operation_timeout', cleanup: { confirmed: false } })).toBe(true);
|
||||
});
|
||||
|
||||
test('the actual driver entry refuses an unapproved host without exposing its request', () => {
|
||||
for (const executable of [process.execPath, Bun.which('node')!]) {
|
||||
const result = spawnSync(executable, [driverFile, '/private-untrusted/account.json'], {
|
||||
env: { PATH: '/usr/bin:/bin', HOME: root }, input: '{"private":"request-value"}', encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual({ protocol: 1, ready: false, error: 'driver_admission_failed', cleanup: { confirmed: false } });
|
||||
}
|
||||
});
|
||||
|
||||
test('the real bounded stdin callback works under both runtimes and refuses raw request payloads', () => {
|
||||
const entry = path.join(root, 'stdin-probe.mjs');
|
||||
writeFileSync(entry, `import { readComparisonRequest } from ${JSON.stringify(pathToFileURL(driverFile).href)};
|
||||
try { const request = readComparisonRequest(); process.stdout.write(JSON.stringify({purpose:request.purpose})); }
|
||||
catch { process.stdout.write(JSON.stringify({rejected:true})); }`);
|
||||
for (const executable of [process.execPath, Bun.which('node')!]) {
|
||||
for (const input of ['{"purpose":"control"}', '{"purpose":"control","env":{"PRIVATE":"value"}}', 'x'.repeat(16 * 1024 + 1)]) {
|
||||
const result = spawnSync(executable, [entry], { env: { HOME: root, PATH: '/usr/bin:/bin' }, input, encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual(input === '{"purpose":"control"}' ? { purpose: 'control' } : { rejected: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
test('both pinned runtimes execute the same protected worker with matched normalized inputs', async () => {
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node 24.18 is required for the comparison regression');
|
||||
const entry = path.join(root, 'probe.mjs');
|
||||
const executablePath = realpathSync((await import('playwright')).chromium.executablePath());
|
||||
writeFileSync(entry, `import { runProtectedLaunch } from ${JSON.stringify(pathToFileURL(driverFile).href)};
|
||||
const home = process.env.HOME;
|
||||
const result = await runProtectedLaunch(${JSON.stringify(executablePath)}, home + '/profile',
|
||||
{ HOME: home, TMPDIR: home + '/tmp', PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' }, 'control', { home });
|
||||
process.stdout.write(JSON.stringify(result) + '\\n');`);
|
||||
const results: any[] = [];
|
||||
for (const [name, executable] of [['bun', process.execPath], ['node', node]]) {
|
||||
const home = path.join(root, name);
|
||||
mkdirSync(home);
|
||||
mkdirSync(path.join(home, 'tmp'));
|
||||
const result = spawnSync(executable, [entry], { env: { HOME: home, PATH: '/usr/bin:/bin:/usr/sbin:/sbin' },
|
||||
encoding: 'utf8', timeout: 40_000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const observed = JSON.parse(result.stdout);
|
||||
expect(observed.ready).toBe(true);
|
||||
expect(observed.protocolResponded).toBe(true);
|
||||
expect(observed.cleanup.confirmed).toBe(true);
|
||||
expect(observed.cleanup.childClosed).toBe(true);
|
||||
expect(observed.cleanup.groupAbsent).toBe(true);
|
||||
expect(observed.cleanup.launchSettled).toBe(true);
|
||||
expect(observed.rootCount).toBe(1);
|
||||
expect(observed.launchAttempts[0].pipeFlag).toBe(true);
|
||||
expect(observed.launchAttempts[0].sandboxDisablingFlag).toBe(false);
|
||||
expect(observed.samplingEnabled).toBe(false);
|
||||
expect(safeDiaComparisonResponse(observed)).toBe(true);
|
||||
expect(JSON.stringify(observed)).not.toContain(home);
|
||||
results.push(observed);
|
||||
}
|
||||
expect(results[0].argvSha256).toBe(results[1].argvSha256);
|
||||
expect(results[0].environmentSha256).toBe(results[1].environmentSha256);
|
||||
}, 90_000);
|
||||
|
||||
const arm = (runtime: 'bun' | 'node', ready: boolean) => {
|
||||
const sha = 'a'.repeat(64);
|
||||
const config = { mode: 'launch-only', runtime, qualificationCredit: false, executableSha256: runtime === 'bun' ? sha : 'b'.repeat(64), driverSha256: sha, helpersSha256: sha };
|
||||
const driver = { runtime, version: runtime === 'bun' ? '1.4.0' : '24.18.0', architecture: 'arm64', os: 'darwin', release: '24.6.0', playwright: '1.62.1',
|
||||
executableSha256: config.executableSha256, driverSha256: sha, helpersSha256: sha };
|
||||
const result = (purpose: string, success: boolean) => ({ protocol: 1, purpose, ready: success, launchReturned: success, protocolResponded: success,
|
||||
samplingEnabled: false, rootCount: 1, supervisor: { closed: true, exitCode: 0 }, cleanup: { confirmed: true, childClosed: true, groupAbsent: true, launchSettled: true,
|
||||
groups: [{ absenceConfirmed: true, childCloseObserved: true }] }, startupPages: { allowed: true }, postProbePages: { allowed: true },
|
||||
argvSha256: sha, environmentSha256: sha, driver, launchAttempts: [{ sandboxRequired: true, sandboxDisablingFlag: false, pipeFlag: true,
|
||||
tcpDebuggingFlag: false, mockKeychainFlag: false, passwordStoreFlag: false, firstRunSuppressed: false, headlessFlag: true,
|
||||
expectedProfile: true, detached: true, shellDisabled: true, stdioCount: 5, extraPipeDescriptors: true, profileArgumentCount: 1 }] });
|
||||
return { launchComparison: config, counts: { pass: 0, fail: 0, skip: 0 }, launcher: { accountGuid: runtime + '-separate-account', sourceRevision: 'c'.repeat(40), archiveSha256: sha, destinationSha256: sha },
|
||||
launcherCleanup: { serviceStopped: true, userDomainStopped: true, userProcessesStopped: true, accountRemoved: true, groupRemoved: true, stagingRemoved: true },
|
||||
backgroundPreflight: { status: 'passed', comparisonControl: result('control', true) },
|
||||
qualification: { launchComparison: { qualificationCredit: false, source: result('source', ready) }, counts: { pass: 0, fail: 0, skip: 0 },
|
||||
keychainStage: 'completed', isolation: { registeredIdentity: true, sharedRegisteredHome: true },
|
||||
cleanup: { ownedBrowsersStopped: true, sourceProfileRemoved: true, keychainRestored: true, mountDetached: true, fixtureRemoved: true },
|
||||
platform: { os: 'darwin', architecture: 'arm64', bun: '1.4.0', playwright: '1.62.1', release: '24.6.0' },
|
||||
artifact: { signatureVerified: true, gatekeeperNotarized: true, macosCompatibility: { compatible: true, hostVersion: '15.7.9' },
|
||||
architectures: ['arm64'], sha256: sha, executableSha256: sha, version: '1.49.1', bundleId: 'company.thebrowser.dia', team: 'S6N382Y83G' } } };
|
||||
};
|
||||
|
||||
test('paired receipts distinguish runtime readiness without awarding qualification credit', () => {
|
||||
expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', true))).toEqual({ comparable: true, qualificationCredit: false, outcome: 'node_only_ready' });
|
||||
expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', false)).outcome).toBe('neither_ready');
|
||||
expect(compareDiaLaunchReceipts(arm('bun', true), arm('node', true)).outcome).toBe('both_ready');
|
||||
});
|
||||
|
||||
test('incomplete or incompatible arms cannot support a runtime conclusion', () => {
|
||||
const left = arm('bun', false);
|
||||
const mutations = [
|
||||
(right: any) => { right.backgroundPreflight.comparisonControl.ready = false; },
|
||||
(right: any) => { right.launcherCleanup.userDomainStopped = false; },
|
||||
(right: any) => { right.qualification.cleanup.sourceProfileRemoved = false; },
|
||||
(right: any) => { right.qualification.artifact.executableSha256 = 'b'.repeat(64); },
|
||||
(right: any) => { right.qualification.launchComparison.source.argvSha256 = 'b'.repeat(64); },
|
||||
(right: any) => { right.qualification.launchComparison.source.launchAttempts[0].sandboxDisablingFlag = true; },
|
||||
(right: any) => { right.qualification.launchComparison.source.driver.version = '24.19.0'; },
|
||||
(right: any) => { right.qualification.launchComparison.source.cleanup.launchSettled = false; },
|
||||
(right: any) => { right.counts.pass = 1; },
|
||||
(right: any) => { right.launcher.accountGuid = left.launcher.accountGuid; },
|
||||
(right: any) => { delete right.qualification.platform; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const right = arm('node', true);
|
||||
mutate(right);
|
||||
expect(compareDiaLaunchReceipts(left, right).comparable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('comparison supervisor binds code hashes and rejects extra private response fields', async () => {
|
||||
const work = path.join(root, 'ipc');
|
||||
mkdirSync(path.join(work, 'bin'), { recursive: true });
|
||||
mkdirSync(path.join(work, 'repo/.github/scripts'), { recursive: true });
|
||||
const executable = path.join(work, 'bin/node');
|
||||
const driver = path.join(work, 'repo/.github/scripts/dia-launch-driver.mjs');
|
||||
const helpers = path.join(work, 'repo/.github/scripts/qualify-dia-macos.ts');
|
||||
for (const file of [executable, driver, helpers]) writeFileSync(file, 'synthetic code');
|
||||
const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const account: any = { work, snapshot: path.join(work, 'repo'), configFile: path.join(work, 'account.json'), environment: { HOME: work },
|
||||
launchComparison: { mode: 'launch-only', runtime: 'node', executable, executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } };
|
||||
const response: any = { protocol: 1, purpose: 'control', ready: false, launchReturned: false, samplingEnabled: false, cleanup: { confirmed: false },
|
||||
driver: { runtime: 'node', version: '24.18.0', architecture: 'arm64', os: 'darwin', playwright: '1.62.1',
|
||||
executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } };
|
||||
let calls = 0;
|
||||
const execute = ((command: string, args: string[], options: any) => {
|
||||
calls++;
|
||||
expect(command).toBe(executable);
|
||||
expect(args).toEqual([driver, account.configFile]);
|
||||
expect(JSON.parse(options.input)).toEqual({ purpose: 'control' });
|
||||
expect(options.timeout).toBeLessThanOrEqual(40_000);
|
||||
expect(options.maxBuffer).toBe(64 * 1024);
|
||||
return { status: 0, stdout: JSON.stringify(response), stderr: '' };
|
||||
}) as typeof spawnSync;
|
||||
expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).supervisor.exitCode).toBe(0);
|
||||
response.error = 'private-error-value';
|
||||
expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).error).toBe('driver_exchange_failed');
|
||||
writeFileSync(executable, 'changed executable');
|
||||
await expect(runDiaLaunchComparison(account, 'control', undefined, execute)).rejects.toThrow('comparison_driver_inputs_changed');
|
||||
expect(calls).toBe(2);
|
||||
await expect(runDiaLaunchComparison({ ...account, launchComparison: undefined }, 'control', undefined, execute)).rejects.toThrow('comparison_driver_authority_missing');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+125
@@ -0,0 +1,125 @@
|
||||
import { lstatSync, unlinkSync } from 'node:fs';
|
||||
import { toNamespacedPath } from 'node:path';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
type Identity = { dev: bigint; ino: bigint; mode: bigint };
|
||||
type Handle = number | bigint;
|
||||
type Stage = 'admission' | 'identity' | 'disposition' | 'close' | 'absence';
|
||||
|
||||
export class FixtureDeleteError extends Error {
|
||||
readonly code: string;
|
||||
readonly syscall: string;
|
||||
constructor(public stage: Stage, public path: string, public win32Error?: number, public deadlineExceeded = false) {
|
||||
super(`Owned fixture deletion failed at ${stage}${deadlineExceeded ? ' (deadline)' : win32Error === undefined ? '' : ` (Windows ${win32Error})`}`);
|
||||
this.name = 'FixtureDeleteError';
|
||||
this.code = deadlineExceeded ? 'ETIMEDOUT' : win32Error === 32 ? 'EBUSY' : win32Error === 2 || win32Error === 3 ? 'ENOENT' : win32Error === 5 ? 'EACCES' : 'EIO';
|
||||
this.syscall = { admission: 'open', identity: 'fstat', disposition: 'unlink', close: 'close', absence: 'lstat' }[stage];
|
||||
}
|
||||
}
|
||||
|
||||
export interface FixtureDeleteBackend {
|
||||
open(file: string): Handle;
|
||||
identity(handle: Handle, file: string): { dev: bigint; ino: bigint; attributes: number; filesystem: string };
|
||||
dispose(handle: Handle, file: string): void;
|
||||
close(handle: Handle, file: string): void;
|
||||
absent(file: string): boolean;
|
||||
}
|
||||
|
||||
type LeaseClock = { now(): number; wait(ms: number): void; onSharing?(): void };
|
||||
const leaseClock: LeaseClock = { now: () => performance.now(), wait: ms => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } };
|
||||
|
||||
export function deleteWithFixtureLease(file: string, expected: Identity, deadline: number, verify: () => void,
|
||||
backend: FixtureDeleteBackend, clock: LeaseClock = leaseClock): { admissionProbes: number; waitedMs: number } {
|
||||
if (!Number.isFinite(deadline)) throw new Error('Invalid fixture deletion deadline');
|
||||
let handle: Handle | undefined;
|
||||
let firstSharing: FixtureDeleteError | undefined;
|
||||
let admissionProbes = 0;
|
||||
let waitedMs = 0;
|
||||
while (handle === undefined) {
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
verify();
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
try { admissionProbes++; handle = backend.open(file); }
|
||||
catch (error) {
|
||||
if (!(error instanceof FixtureDeleteError) || error.stage !== 'admission' || error.win32Error !== 32) throw error;
|
||||
if (!firstSharing) { firstSharing = error; clock.onSharing?.(); }
|
||||
const remaining = deadline - clock.now();
|
||||
if (remaining <= 0) throw firstSharing;
|
||||
const before = clock.now();
|
||||
clock.wait(Math.min(20, remaining));
|
||||
waitedMs += Math.max(0, clock.now() - before);
|
||||
}
|
||||
}
|
||||
let failed = false;
|
||||
let failure: unknown;
|
||||
try {
|
||||
const current = backend.identity(handle, file);
|
||||
if (current.filesystem !== 'NTFS' || current.dev !== expected.dev || current.ino !== expected.ino
|
||||
|| (current.attributes & (0x1 | 0x10 | 0x400)) !== 0) throw new FixtureDeleteError('identity', file);
|
||||
verify();
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
backend.dispose(handle, file);
|
||||
} catch (error) { failed = true; failure = error; }
|
||||
try { backend.close(handle, file); }
|
||||
catch (error) {
|
||||
if (failed) console.error(JSON.stringify({ nativeFixtureDeleteCloseFailure: {
|
||||
stage: error instanceof FixtureDeleteError ? error.stage : 'close',
|
||||
win32Error: error instanceof FixtureDeleteError ? error.win32Error : undefined,
|
||||
} }));
|
||||
else { failed = true; failure = error; }
|
||||
}
|
||||
if (failed) throw failure;
|
||||
if (!backend.absent(file)) throw new FixtureDeleteError('absence', file);
|
||||
return { admissionProbes, waitedMs };
|
||||
}
|
||||
|
||||
export function createFixtureDeleteLease(deadline: number, onSharing?: () => void) {
|
||||
if (process.platform !== 'win32') return { unlink: (file: string, _identity: Identity, _verify: () => void) => unlinkSync(file), close: () => {} };
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
GetFileInformationByHandle: { args: [FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
||||
GetVolumeInformationByHandleW: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
SetFileInformationByHandle: { args: [FFIType.u64, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
const backend: FixtureDeleteBackend = {
|
||||
open(file) {
|
||||
const name = Buffer.from(toNamespacedPath(file) + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x10080, 3, null, 3, 0x00200000, 0);
|
||||
const error = kernel.symbols.GetLastError();
|
||||
if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new FixtureDeleteError('admission', file, error);
|
||||
return handle;
|
||||
},
|
||||
identity(handle, file) {
|
||||
const info = Buffer.alloc(52);
|
||||
if (!kernel.symbols.GetFileInformationByHandle(handle, ptr(info))) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError());
|
||||
const filesystem = Buffer.alloc(128);
|
||||
if (!kernel.symbols.GetVolumeInformationByHandleW(handle, null, 0, null, null, null, ptr(filesystem), 64)) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError());
|
||||
return { dev: BigInt(info.readUInt32LE(28)), ino: (BigInt(info.readUInt32LE(44)) << 32n) | BigInt(info.readUInt32LE(48)),
|
||||
attributes: info.readUInt32LE(0), filesystem: filesystem.toString('utf16le').split('\0')[0] };
|
||||
},
|
||||
dispose(handle, file) {
|
||||
const flags = Buffer.alloc(4);
|
||||
flags.writeUInt32LE(3);
|
||||
if (!kernel.symbols.SetFileInformationByHandle(handle, 21, ptr(flags), 4)) throw new FixtureDeleteError('disposition', file, kernel.symbols.GetLastError());
|
||||
},
|
||||
close(handle, file) {
|
||||
if (!kernel.symbols.CloseHandle(handle)) throw new FixtureDeleteError('close', file, kernel.symbols.GetLastError());
|
||||
},
|
||||
absent(file) {
|
||||
try { lstatSync(file); return false; }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; throw error; }
|
||||
},
|
||||
};
|
||||
return {
|
||||
unlink(file: string, identity: Identity, verify: () => void) {
|
||||
if ((identity.mode & 0o170000n) !== 0o100000n) { verify(); unlinkSync(file); return; }
|
||||
const receipt = deleteWithFixtureLease(file, identity, deadline, verify, backend, { ...leaseClock, onSharing });
|
||||
if (receipt.admissionProbes > 1) console.log(JSON.stringify({ nativeFixtureSharingAdmission: {
|
||||
objectDev: identity.dev.toString(), objectIno: identity.ino.toString(), ...receipt, dispositionCalls: 1,
|
||||
} }));
|
||||
},
|
||||
close() { kernel.close(); },
|
||||
};
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { lstatSync, realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
let stage = 'platform';
|
||||
|
||||
class FileOwnerProbeError extends Error {
|
||||
errorCode?: string;
|
||||
constructor(public stage: string, error: unknown) {
|
||||
super('owner_query_failed');
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTDIR', 'ENAMETOOLONG', 'ETIMEDOUT'].includes(code || '')) this.errorCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
function inspect() {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
||||
stage = 'input_decode';
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
if (typeof input.root !== 'string' || typeof input.file !== 'string' || !Number.isSafeInteger(input.testPid)) return { available: false, reason: 'invalid_input' };
|
||||
stage = 'resolve_root';
|
||||
const root = realpathSync(input.root);
|
||||
stage = 'resolve_file';
|
||||
const file = realpathSync(input.file);
|
||||
const relative = path.relative(root, file);
|
||||
stage = 'file_stat';
|
||||
const initial = lstatSync(input.file, { bigint: true });
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative) || !initial.isFile() || initial.isSymbolicLink()
|
||||
|| relative !== path.relative(root, path.resolve(input.file))) return { available: false, reason: 'outside_owned_fixture' };
|
||||
if (input.expectedIdentity !== undefined && (input.expectedIdentity?.dev !== initial.dev.toString()
|
||||
|| input.expectedIdentity?.ino !== initial.ino.toString())) return { available: false, reason: 'failed_object_identity_changed' };
|
||||
const unchanged = () => {
|
||||
stage = 'file_recheck';
|
||||
const current = lstatSync(input.file, { bigint: true });
|
||||
return current.isFile() && !current.isSymbolicLink() && current.dev === initial.dev && current.ino === initial.ino
|
||||
&& realpathSync(input.file) === file;
|
||||
};
|
||||
stage = 'load_restart_manager';
|
||||
const restart = dlopen(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'rstrtmgr.dll'), {
|
||||
RmStartSession: { args: [FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmRegisterResources: { args: [FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmGetList: { args: [FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmEndSession: { args: [FFIType.u32], returns: FFIType.u32 },
|
||||
});
|
||||
stage = 'load_kernel';
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
GetProcessTimes: { args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
let session: number | undefined;
|
||||
try {
|
||||
const sessionBuffer = Buffer.alloc(4);
|
||||
const key = Buffer.alloc(66);
|
||||
stage = 'session_start';
|
||||
let status = restart.symbols.RmStartSession(ptr(sessionBuffer), 0, ptr(key));
|
||||
if (status !== 0) return { available: false, reason: 'session_start', status };
|
||||
session = sessionBuffer.readUInt32LE(0);
|
||||
const wideFile = Buffer.from(file + '\0', 'utf16le');
|
||||
const names = Buffer.alloc(8);
|
||||
names.writeBigUInt64LE(BigInt(ptr(wideFile)));
|
||||
stage = 'register_file';
|
||||
status = restart.symbols.RmRegisterResources(session, 1, ptr(names), 0, null, 0, null);
|
||||
if (status !== 0) return { available: false, reason: 'register_file', status };
|
||||
const needed = Buffer.alloc(4);
|
||||
const count = Buffer.alloc(4);
|
||||
const rebootReasons = Buffer.alloc(4);
|
||||
stage = 'owner_count';
|
||||
status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), null, ptr(rebootReasons));
|
||||
if (status === 0 && needed.readUInt32LE(0) === 0) return unchanged()
|
||||
? { available: true, owners: [], rebootReasons: rebootReasons.readUInt32LE(0) }
|
||||
: { available: false, reason: 'failed_object_identity_changed' };
|
||||
const entries = needed.readUInt32LE(0);
|
||||
if (status !== 234 || entries < 1 || entries > 64) return { available: false, reason: 'owner_count', status, entries };
|
||||
const information = Buffer.alloc(entries * 668);
|
||||
count.writeUInt32LE(entries);
|
||||
stage = 'owner_list';
|
||||
status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), ptr(information), ptr(rebootReasons));
|
||||
const returned = count.readUInt32LE(0);
|
||||
if (status !== 0 || returned > entries) return { available: false, reason: 'owner_list', status };
|
||||
const owners = [];
|
||||
stage = 'owner_identity';
|
||||
for (let index = 0; index < returned; index++) {
|
||||
const offset = index * 668;
|
||||
const pid = information.readUInt32LE(offset);
|
||||
const recordedStart = information.readBigUInt64LE(offset + 4);
|
||||
let image = 'unavailable';
|
||||
let creationMatched = false;
|
||||
const handle = kernel.symbols.OpenProcess(0x1000, 0, pid);
|
||||
if (handle) {
|
||||
try {
|
||||
const times = Buffer.alloc(32);
|
||||
const timeAddress = ptr(times);
|
||||
if (kernel.symbols.GetProcessTimes(handle, timeAddress, timeAddress + 8, timeAddress + 16, timeAddress + 24)) {
|
||||
creationMatched = times.readBigUInt64LE(0) === recordedStart;
|
||||
}
|
||||
if (creationMatched) {
|
||||
const imageBuffer = Buffer.alloc(65536);
|
||||
const imageLength = Buffer.alloc(4);
|
||||
imageLength.writeUInt32LE(32768);
|
||||
if (kernel.symbols.QueryFullProcessImageNameW(handle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
||||
const chars = imageLength.readUInt32LE(0);
|
||||
const name = chars <= 32768 ? path.basename(imageBuffer.subarray(0, chars * 2).toString('utf16le')).toLowerCase() : '';
|
||||
image = ['bun.exe', 'node.exe', 'msedge.exe', 'msmpeng.exe', 'mssense.exe', 'dllhost.exe', 'explorer.exe', 'powershell.exe', 'pwsh.exe', 'svchost.exe', 'conhost.exe'].includes(name) ? name : 'other';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
kernel.symbols.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
owners.push({ pid, image, creationMatched, isTestHost: creationMatched && pid === input.testPid, applicationType: information.readUInt32LE(offset + 652) });
|
||||
}
|
||||
return unchanged() ? { available: true, owners, rebootReasons: rebootReasons.readUInt32LE(0) }
|
||||
: { available: false, reason: 'failed_object_identity_changed' };
|
||||
} catch (error) {
|
||||
throw new FileOwnerProbeError(stage, error);
|
||||
} finally {
|
||||
stage = 'session_end';
|
||||
if (session !== undefined) restart.symbols.RmEndSession(session);
|
||||
stage = 'library_close';
|
||||
kernel.close(); restart.close();
|
||||
}
|
||||
}
|
||||
|
||||
let result: object;
|
||||
try { result = inspect(); }
|
||||
catch (error) {
|
||||
const failure = error instanceof FileOwnerProbeError ? error : new FileOwnerProbeError(stage, error);
|
||||
result = { available: false, reason: 'owner_query_failed', stage: failure.stage, errorCode: failure.errorCode };
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0));
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
const fs = require('node:fs');
|
||||
const cp = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = ({ observation, playwrightEntry, mode = 'normal-close', inspectCommandLine = false, observerExecutable, seedCookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' } }) => {
|
||||
if (inspectCommandLine && process.platform === 'win32' && typeof observerExecutable !== 'string') throw new Error('Native observer executable is required');
|
||||
const originalSpawn = cp.spawn;
|
||||
let inspected = Promise.resolve();
|
||||
let folderEvidence;
|
||||
const directoryState = (env, root) => ({
|
||||
requestedProfile: fs.existsSync(root),
|
||||
localEnvironment: fs.existsSync(env.LOCALAPPDATA || ''),
|
||||
roamingEnvironment: fs.existsSync(env.APPDATA || ''),
|
||||
localUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Local')),
|
||||
roamingUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Roaming')),
|
||||
});
|
||||
const safeFolders = value => Object.fromEntries(['local', 'roaming'].map(name => [name,
|
||||
Object.fromEntries(['verified', 'dontVerify'].map(kind => {
|
||||
const item = value?.[name]?.[kind];
|
||||
return [kind, {
|
||||
hresult: Number.isInteger(item?.hresult) ? item.hresult : null,
|
||||
pathHash: /^[a-f0-9]{64}$/.test(item?.pathHash) ? item.pathHash : null,
|
||||
exists: typeof item?.exists === 'boolean' ? item.exists : null,
|
||||
matchesEnvironment: typeof item?.matchesEnvironment === 'boolean' ? item.matchesEnvironment : null,
|
||||
underUserProfile: typeof item?.underUserProfile === 'boolean' ? item.underUserProfile : null,
|
||||
}];
|
||||
})),
|
||||
]));
|
||||
const runProbe = (input, env) => new Promise(resolve => {
|
||||
const probe = originalSpawn(observerExecutable, [
|
||||
'--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(__dirname, 'native-cookie-process-observer.ts'),
|
||||
Buffer.from(JSON.stringify(input)).toString('base64'),
|
||||
], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
||||
let output = '';
|
||||
let stderrBytes = 0;
|
||||
let spawnFailed = false;
|
||||
const timer = setTimeout(() => probe.kill(), 5_000);
|
||||
probe.stdout.on('data', chunk => { output += chunk.toString('utf8'); if (output.length > 16384) probe.kill(); });
|
||||
probe.stderr.on('data', chunk => { stderrBytes += chunk.length; });
|
||||
probe.once('error', () => { spawnFailed = true; });
|
||||
probe.once('close', code => {
|
||||
clearTimeout(timer);
|
||||
try { resolve({ measured: JSON.parse(output), code, stderrBytes }); }
|
||||
catch { resolve({ measured: { available: false, reason: spawnFailed ? 'probe_spawn_failed' : 'probe_no_receipt' }, code, stderrBytes }); }
|
||||
});
|
||||
});
|
||||
cp.spawn = function(command, args, options) {
|
||||
if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native fixture refuses a sandbox-disabled browser');
|
||||
const child = originalSpawn.call(this, command, args, options);
|
||||
const evidence = {
|
||||
command, args, pid: child.pid,
|
||||
argsHash: createHash('sha256').update(JSON.stringify(args)).digest('hex'),
|
||||
envHash: createHash('sha256').update(JSON.stringify(Object.entries(options.env || {}).sort(([a], [b]) => a.localeCompare(b)))).digest('hex'),
|
||||
stderrBytes: 0,
|
||||
reasons: [],
|
||||
runtime: { node: process.version, bun: process.versions.bun || null, architecture: process.arch },
|
||||
folderEvidence,
|
||||
};
|
||||
const publish = () => fs.writeFileSync(observation, JSON.stringify(evidence));
|
||||
publish();
|
||||
let tail = '';
|
||||
child.stderr?.on('data', chunk => {
|
||||
evidence.stderrBytes += chunk.length;
|
||||
if (evidence.stderrBytes > 65536) return;
|
||||
const text = tail + chunk.toString('utf8');
|
||||
const patterns = {
|
||||
job_assignment_failed: /AssignProcessToJobObject|failed to (?:assign|create).*job object/i,
|
||||
sandbox_failed: /sandbox.*(?:failed|error)|SBOX_FATAL/i,
|
||||
profile_locked: /ProcessSingleton|profile.*in use/i,
|
||||
default_profile_policy: /remote debugging requires a non-default data directory/i,
|
||||
permission_denied: /access is denied|ERROR_ACCESS_DENIED|permission denied/i,
|
||||
crashpad_failed: /crashpad.*(?:failed|error)/i,
|
||||
missing_dependency: /specified module could not be found|0xc0000135/i,
|
||||
};
|
||||
for (const [reason, pattern] of Object.entries(patterns)) {
|
||||
if (pattern.test(text) && !evidence.reasons.includes(reason)) evidence.reasons.push(reason);
|
||||
}
|
||||
tail = text.slice(-512);
|
||||
publish();
|
||||
});
|
||||
child.once('exit', (code, signal) => { evidence.exitCode = code; evidence.signal = signal; publish(); });
|
||||
child.once('error', error => {
|
||||
evidence.spawnError = ['ENOENT', 'EACCES', 'EPERM', 'EINVAL'].includes(error.code) ? error.code : 'spawn_failed';
|
||||
publish();
|
||||
});
|
||||
if (inspectCommandLine && process.platform === 'win32' && Number.isInteger(child.pid)) {
|
||||
inspected = runProbe({ pid: child.pid, owner: process.pid, image: command }, options.env).then(({ measured, code, stderrBytes }) => {
|
||||
const expectedHashes = args.map(arg => createHash('sha256').update(arg).digest('hex'));
|
||||
const dataDir = args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16);
|
||||
evidence.observedCommandLine = {
|
||||
available: measured.available === true,
|
||||
parentMatched: measured.parentMatched === true,
|
||||
imageMatched: measured.imageMatched === true,
|
||||
reason: ['not_windows', 'invalid_input', 'process_open', 'process_identity', 'owned_process_unavailable', 'command_line', 'command_line_length', 'command_line_bounds', 'argument_parse', 'argument_bounds', 'job_query', 'observer_initialize', 'probe_spawn_failed', 'probe_no_receipt'].includes(measured.reason) ? measured.reason : undefined,
|
||||
win32Error: Number.isInteger(measured.win32Error) ? measured.win32Error : undefined,
|
||||
ntStatus: Number.isInteger(measured.ntStatus) ? measured.ntStatus : undefined,
|
||||
commandLineHash: /^[a-f0-9]{64}$/.test(measured.commandLineHash) ? measured.commandLineHash : undefined,
|
||||
argumentsMatchRequested: measured.available === true && JSON.stringify(measured.argumentHashes) === JSON.stringify(expectedHashes),
|
||||
userDataDirCount: Number.isInteger(measured.userDataDirCount) && measured.userDataDirCount >= 0 && measured.userDataDirCount <= 128 ? measured.userDataDirCount : undefined,
|
||||
userDataDirMatchesRequested: typeof dataDir === 'string' && measured.userDataDirHash === createHash('sha256').update(dataDir).digest('hex'),
|
||||
pipePresent: measured.pipePresent === true,
|
||||
browserInJob: typeof measured.browserInJob === 'boolean' ? measured.browserInJob : undefined,
|
||||
observerJobLimitFlags: Number.isInteger(measured.observerJobLimitFlags) ? measured.observerJobLimitFlags : undefined,
|
||||
observerJobQueryError: Number.isInteger(measured.observerJobQueryError) ? measured.observerJobQueryError : undefined,
|
||||
exitCode: code, stderrBytes,
|
||||
};
|
||||
evidence.folderEvidence.afterLaunch = safeFolders(measured.knownFolders);
|
||||
evidence.folderEvidence.directoriesAfterLaunch = directoryState(options.env, args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16) || '');
|
||||
publish();
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
const { chromium } = require(playwrightEntry);
|
||||
return { chromium: { async launchPersistentContext(root, options) {
|
||||
if (options.chromiumSandbox !== true) throw new Error('Native fixture requires the browser sandbox');
|
||||
const started = Date.now();
|
||||
if (inspectCommandLine && process.platform === 'win32') {
|
||||
const directoriesBefore = directoryState(options.env, root);
|
||||
const before = await runProbe({ mode: 'known-folders' }, options.env);
|
||||
folderEvidence = { beforeLaunch: safeFolders(before.measured.knownFolders), directoriesBefore, directoriesAfterProbe: directoryState(options.env, root) };
|
||||
}
|
||||
const context = await chromium.launchPersistentContext(root, inspectCommandLine && process.platform === 'win32'
|
||||
? { ...options, timeout: Math.max(1, options.timeout - (Date.now() - started)) } : options);
|
||||
await inspected;
|
||||
await context.addCookies([seedCookie]);
|
||||
if (mode === 'stalled-close') context.close = () => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); };
|
||||
return context;
|
||||
} } };
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dlopen, FFIType, ptr, toArrayBuffer } from 'bun:ffi';
|
||||
|
||||
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
export function decodeNativeCommandLine(buffer: Buffer, address: number | bigint): string | null {
|
||||
if (buffer.length < 16) return null;
|
||||
const length = buffer.readUInt16LE(0);
|
||||
const offset = Number(buffer.readBigUInt64LE(8) - BigInt(address));
|
||||
if (!Number.isSafeInteger(offset) || offset < 16 || offset + length > buffer.length || length % 2 !== 0) return null;
|
||||
return buffer.subarray(offset, offset + length).toString('utf16le');
|
||||
}
|
||||
|
||||
function knownFolders() {
|
||||
const shell = dlopen('shell32.dll', {
|
||||
SHGetFolderPathW: { args: [FFIType.u64, FFIType.i32, FFIType.u64, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
});
|
||||
const normalized = (value: string) => path.win32.normalize(value).toLowerCase();
|
||||
try {
|
||||
return Object.fromEntries([['local', 0x1c, 'LOCALAPPDATA'], ['roaming', 0x1a, 'APPDATA']].map(([name, id, env]) => {
|
||||
const calls = Object.fromEntries([['verified', 0], ['dontVerify', 0x4000]].map(([kind, flag]) => {
|
||||
const output = Buffer.alloc(520);
|
||||
const status = shell.symbols.SHGetFolderPathW(0, Number(id) | Number(flag), 0, 0, ptr(output));
|
||||
let end = 0;
|
||||
while (end + 2 <= output.length && output.readUInt16LE(end) !== 0) end += 2;
|
||||
const folder = status >= 0 && end > 0 && end + 2 <= output.length ? output.subarray(0, end).toString('utf16le') : null;
|
||||
return [kind, {
|
||||
hresult: status,
|
||||
pathHash: folder ? hash(normalized(folder)) : null,
|
||||
exists: folder ? existsSync(folder) : null,
|
||||
matchesEnvironment: folder ? normalized(folder) === normalized(process.env[String(env)] || '') : null,
|
||||
underUserProfile: folder ? normalized(folder).startsWith(normalized(process.env.USERPROFILE || '') + '\\') : null,
|
||||
}];
|
||||
}));
|
||||
return [name, calls];
|
||||
}));
|
||||
} finally {
|
||||
shell.close();
|
||||
}
|
||||
}
|
||||
|
||||
function observe() {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
if (input.mode === 'known-folders') return { available: true, knownFolders: knownFolders() };
|
||||
if (!Number.isSafeInteger(input.pid) || input.pid <= 0 || input.pid > 0xffffffff || !Number.isSafeInteger(input.owner) || input.owner <= 0 || input.owner > 0xffffffff || typeof input.image !== 'string' || input.image.length > 32768) {
|
||||
return { available: false, reason: 'invalid_input' };
|
||||
}
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
IsProcessInJob: { args: [FFIType.u64, FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
||||
LocalFree: { args: [FFIType.ptr], returns: FFIType.ptr },
|
||||
LocalSize: { args: [FFIType.ptr], returns: FFIType.u64 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
const nt = dlopen('ntdll.dll', {
|
||||
NtQueryInformationProcess: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
});
|
||||
const shell = dlopen('shell32.dll', {
|
||||
CommandLineToArgvW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
|
||||
});
|
||||
let processHandle: number | bigint = 0;
|
||||
let argumentMemory: ReturnType<typeof shell.symbols.CommandLineToArgvW> = null;
|
||||
let stage = 'process_open';
|
||||
try {
|
||||
processHandle = kernel.symbols.OpenProcess(0x1000, 0, input.pid);
|
||||
if (!processHandle) return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
||||
stage = 'process_identity';
|
||||
const basic = Buffer.alloc(48);
|
||||
const returned = Buffer.alloc(4);
|
||||
let status = nt.symbols.NtQueryInformationProcess(processHandle, 0, ptr(basic), basic.length, ptr(returned));
|
||||
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
||||
const parentMatched = basic.readBigUInt64LE(40) === BigInt(input.owner);
|
||||
const pidMatched = basic.readBigUInt64LE(32) === BigInt(input.pid);
|
||||
const imageBuffer = Buffer.alloc(65536);
|
||||
const imageLength = Buffer.alloc(4);
|
||||
imageLength.writeUInt32LE(32768);
|
||||
if (!kernel.symbols.QueryFullProcessImageNameW(processHandle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
||||
return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
||||
}
|
||||
const imageChars = imageLength.readUInt32LE(0);
|
||||
const imageMatched = imageChars <= 32768 && imageBuffer.subarray(0, imageChars * 2).toString('utf16le').toLowerCase() === input.image.toLowerCase();
|
||||
if (!parentMatched || !pidMatched || !imageMatched) return { available: false, reason: 'owned_process_unavailable', parentMatched, imageMatched };
|
||||
stage = 'command_line';
|
||||
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, null, 0, ptr(returned));
|
||||
const length = returned.readUInt32LE(0);
|
||||
if (length < 16 || length > 131072) return { available: false, reason: 'command_line_length', ntStatus: status };
|
||||
const commandBuffer = Buffer.alloc(length);
|
||||
const commandAddress = ptr(commandBuffer);
|
||||
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, commandAddress, length, ptr(returned));
|
||||
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
||||
const commandLine = decodeNativeCommandLine(commandBuffer, commandAddress);
|
||||
if (commandLine === null) return { available: false, reason: 'command_line_bounds' };
|
||||
stage = 'argument_parse';
|
||||
const wideCommand = Buffer.from(commandLine + '\0', 'utf16le');
|
||||
const count = Buffer.alloc(4);
|
||||
argumentMemory = shell.symbols.CommandLineToArgvW(ptr(wideCommand), ptr(count));
|
||||
const argumentCount = count.readInt32LE(0);
|
||||
if (!argumentMemory || argumentCount < 1 || argumentCount > 4096) return { available: false, reason: stage };
|
||||
const size = Number(kernel.symbols.LocalSize(argumentMemory));
|
||||
if (!Number.isSafeInteger(size) || size < argumentCount * 8 || size > 1048576) return { available: false, reason: 'argument_bounds' };
|
||||
const argumentsBuffer = Buffer.from(toArrayBuffer(argumentMemory, 0, size));
|
||||
const args: string[] = [];
|
||||
for (let index = 1; index < argumentCount; index++) {
|
||||
const start = Number(argumentsBuffer.readBigUInt64LE(index * 8) - BigInt(argumentMemory));
|
||||
if (!Number.isSafeInteger(start) || start < argumentCount * 8 || start % 2 !== 0 || start >= size) return { available: false, reason: 'argument_bounds' };
|
||||
let end = start;
|
||||
while (end + 2 <= size && argumentsBuffer.readUInt16LE(end) !== 0) end += 2;
|
||||
if (end + 2 > size) return { available: false, reason: 'argument_bounds' };
|
||||
args.push(argumentsBuffer.subarray(start, end).toString('utf16le'));
|
||||
}
|
||||
stage = 'job_query';
|
||||
const limits = Buffer.alloc(144);
|
||||
const jobKnown = kernel.symbols.QueryInformationJobObject(0, 9, ptr(limits), limits.length, ptr(returned));
|
||||
const jobError = jobKnown ? undefined : kernel.symbols.GetLastError();
|
||||
const inJob = Buffer.alloc(4);
|
||||
const membershipKnown = kernel.symbols.IsProcessInJob(processHandle, 0, ptr(inJob));
|
||||
const dataArgs = args.filter(arg => arg.startsWith('--user-data-dir='));
|
||||
return {
|
||||
available: true, parentMatched, imageMatched,
|
||||
commandLineHash: hash(commandLine), argumentHashes: args.map(hash),
|
||||
userDataDirCount: dataArgs.length,
|
||||
userDataDirHash: dataArgs.length === 1 ? hash(dataArgs[0].slice(16)) : null,
|
||||
pipePresent: args.includes('--remote-debugging-pipe'),
|
||||
browserInJob: membershipKnown ? inJob.readInt32LE(0) !== 0 : null,
|
||||
observerJobLimitFlags: jobKnown ? limits.readUInt32LE(16) : null,
|
||||
observerJobQueryError: jobError,
|
||||
knownFolders: knownFolders(),
|
||||
};
|
||||
} catch {
|
||||
return { available: false, reason: stage };
|
||||
} finally {
|
||||
if (argumentMemory) kernel.symbols.LocalFree(argumentMemory);
|
||||
if (processHandle) kernel.symbols.CloseHandle(processHandle);
|
||||
shell.close(); nt.close(); kernel.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
let result: object;
|
||||
let exitCode = 0;
|
||||
try { result = observe(); }
|
||||
catch { result = { available: false, reason: 'observer_initialize' }; exitCode = 1; }
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(exitCode));
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
module.exports = ({ pidsFile, mode }) => ({
|
||||
chromium: {
|
||||
async launchPersistentContext() {
|
||||
const child = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
require('node:fs').writeFileSync(pidsFile, JSON.stringify([process.pid, child.pid]));
|
||||
if (mode === 'worker-crash') setTimeout(() => process.exit(3), 100);
|
||||
await new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
let stage = 'input';
|
||||
let root;
|
||||
const samePath = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
try {
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
root = input.root;
|
||||
if (typeof root !== 'string' || typeof input.realpath !== 'string' || typeof input.dev !== 'string' || typeof input.ino !== 'string') throw new Error('invalid_input');
|
||||
stage = 'identity';
|
||||
const state = fs.lstatSync(root, { bigint: true });
|
||||
const identity = {
|
||||
directory: state.isDirectory() && !state.isSymbolicLink(),
|
||||
pathMatches: samePath(fs.realpathSync(root), input.realpath),
|
||||
deviceMatches: state.dev.toString() === input.dev,
|
||||
inodeMatches: state.ino.toString() === input.ino,
|
||||
};
|
||||
if (Object.values(identity).some(value => !value)) {
|
||||
console.log(JSON.stringify({ removed: false, reason: 'identity_mismatch', identity }));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
stage = 'remove';
|
||||
fs.rmSync(root, { recursive: true, force: true, maxRetries: 0 });
|
||||
console.log(JSON.stringify({ removed: !fs.existsSync(root), identity, retries: 0 }));
|
||||
}
|
||||
} catch (error) {
|
||||
const code = ['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTEMPTY', 'ENOTDIR'].includes(error.code) ? error.code : 'filesystem_error';
|
||||
let failingPath = null;
|
||||
let metadata = null;
|
||||
if (typeof root === 'string' && typeof error.path === 'string') {
|
||||
const relative = path.relative(root, error.path);
|
||||
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
||||
failingPath = relative || '.';
|
||||
try {
|
||||
const state = fs.lstatSync(error.path);
|
||||
metadata = { mode: state.mode, directory: state.isDirectory(), link: state.isSymbolicLink() };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ removed: false, stage, code, failingPath, metadata }));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* that could silently remove a fix without breaking compilation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -364,11 +364,38 @@ describe('cookie-import domain validation', () => {
|
||||
expect(block).toContain('does not match current page domain');
|
||||
});
|
||||
|
||||
it('cookie-import-browser handler validates --domain against page hostname', () => {
|
||||
const block = sliceBetween(WRITE_SRC, "case 'cookie-import-browser':", "case 'style':");
|
||||
expect(block).toContain('normalizedDomain');
|
||||
expect(block).toContain('pageHostname');
|
||||
expect(block).toContain('does not match current page domain');
|
||||
it('cookie-import-browser handler validates --domain against page hostname', async () => {
|
||||
const operation = await import('../src/cookie-import-operation');
|
||||
const { handleWriteCommand } = await import('../src/write-commands');
|
||||
const imported = spyOn(operation, 'runCookieImport').mockResolvedValue({
|
||||
browser: 'chromium', profile: 'Profile 2', imported: 2, failed: 0,
|
||||
domainCounts: { '.example.test': 2 }, failureReasons: {}, outcome: 'imported',
|
||||
reset: 'not_requested', verification: { verified: false, reason: 'not_requested' }, message: 'Cookie copy complete.',
|
||||
});
|
||||
let currentUrl = 'https://example.test';
|
||||
const page = { url: () => currentUrl, isClosed: () => false };
|
||||
const session = { getPage: () => page, getActiveFrameOrPage: () => page, getFrame: () => null } as any;
|
||||
const manager = { trackCookieImportDomains() {} } as any;
|
||||
try {
|
||||
for (const [target, domain] of [
|
||||
['https://example.test', 'unrelated.test'],
|
||||
['https://example.test.evil.invalid', 'example.test'],
|
||||
['https://badexample.test', 'example.test'],
|
||||
]) {
|
||||
currentUrl = target;
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--domain', domain], session, manager))
|
||||
.rejects.toMatchObject({ code: 'target_mismatch' });
|
||||
}
|
||||
expect(imported).not.toHaveBeenCalled();
|
||||
currentUrl = 'https://sub.example.test/protected';
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['chromium', '--domain', '.Example.Test.', '--profile', 'Profile 2'], session, manager);
|
||||
expect(imported).toHaveBeenCalledTimes(1);
|
||||
expect(imported.mock.calls[0][0]).toMatchObject({ browser: 'chromium', domains: ['example.test'], profile: 'Profile 2' });
|
||||
expect(imported.mock.calls[0][1]).toEqual({ page, url: currentUrl });
|
||||
expect(result).toContain('Imported 2 cookies from chromium (profile: Profile 2)');
|
||||
} finally {
|
||||
imported.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,10 +42,13 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
|
||||
// isProcessAlive no longer spawns anything (signal-0 on every platform,
|
||||
// #1952) — process-liveness-windows.test.ts pins that it stays
|
||||
// subprocess-free, which is stronger than hiding a window.
|
||||
// powershell DPAPI + tasklist in cookie import.
|
||||
const cookie = SRC('cookie-import-browser.ts');
|
||||
expectHideNearEvery(cookie, "'powershell'");
|
||||
expectHideNearEvery(cookie, "'tasklist'");
|
||||
expect(cookie).not.toContain("'tasklist'");
|
||||
expectHideNearEvery(SRC('cookie-import-native.ts'), 'spawn(bunExecutable');
|
||||
const worker = SRC('cookie-import-native-worker.ts');
|
||||
expectHideNearEvery(worker, 'spawn(process.execPath');
|
||||
expectHideNearEvery(worker, 'spawn(input.request.nodeExecutable');
|
||||
});
|
||||
|
||||
test('icacls calls in file-permissions.ts pass windowsHide', () => {
|
||||
|
||||
Reference in New Issue
Block a user