From 7e60664ced431e50e6ff586761e84872ddcb7f73 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:41:43 -0700 Subject: [PATCH] fix(browser): retire cookie snapshots when preparation fails (#26218) --- .../browser-cookie-chromium-prepare.ts | 363 +++++++++--------- ...r-cookie-import-snapshot-ownership.test.ts | 281 ++++++++++++++ 2 files changed, 469 insertions(+), 175 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-snapshot-ownership.test.ts diff --git a/src/main/browser/browser-cookie-chromium-prepare.ts b/src/main/browser/browser-cookie-chromium-prepare.ts index 8b71b102f13..6253e50116e 100644 --- a/src/main/browser/browser-cookie-chromium-prepare.ts +++ b/src/main/browser/browser-cookie-chromium-prepare.ts @@ -142,185 +142,198 @@ export async function prepareChromiumCookieImport( } } - // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. - sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) - let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null - let colList: string | null = null - let placeholders: string | null = null - if (stagingAvailable) { - // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same - // transient AV handle can make opening it throw — degrade instead of killing the import. - try { - stagingDb = new DatabaseSync(stagingCookiesPath) - // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL - // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image - // import, restoring the unrelated-cookie data loss this format is meant to prevent. - stagingDb.exec('PRAGMA journal_mode = DELETE') - targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols = targetColumnInfo.map((row) => row.name) - colList = targetCols.join(', ') - placeholders = targetCols.map(() => '?').join(', ') - } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${String(err)}`) - stagingAvailable = false - targetColumnInfo = null - colList = null - placeholders = null - closeStagingDb() - // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. - discardStagingFile() - } - } - - // Why (STA-4300): the partition columns drift across Chromium versions, so read the source - // schema rather than assuming a row's missing column means "unpartitioned". - const sourceColumns = new Set( - (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( - (column) => column.name - ) - ) - const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< - string, - unknown - >[] - sourceDb.close() - sourceDb = null - diag(` source has ${sourceRows.length} cookies`) - if (sourceRows.length === 0) { - closeStagingDb() - discardStagingFile() - return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } - } - - // Why (STA-4300): partition fidelity is a property of the source row, even when its value - // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. - const partitionCandidates = sourceRows.flatMap((sourceRow) => { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) - ? [] - : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] - }) - const nativePlan = planImportWrites(partitionCandidates) - const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) - const partitionBySourceRow = new Map( - partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) - ) - // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a - // family we cannot protect is the P0. Refuse before the jar is touched. - if (nativePlan.hasUnrepresentableSkip) { - closeStagingDb() - discardStagingFile() - return { - result: { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + let ownershipTransferred = false + try { + // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. + sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same + // transient AV handle can make opening it throw — degrade instead of killing the import. + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL + // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image + // import, restoring the unrelated-cookie data loss this format is meant to prevent. + stagingDb.exec('PRAGMA journal_mode = DELETE') + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols = targetColumnInfo.map((row) => row.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. + discardStagingFile() } } - } - const needsSourceKey = sourceRows.some((sourceRow) => { - const encrypted = sourceRow.encrypted_value - if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { - return false - } - return ( - !isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) && - !isNonTransplantableCookieDomain(sourceRow.host_key as string) - ) - }) - const sourceKey = needsSourceKey - ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) - : null - if (needsSourceKey && !sourceKey) { - closeStagingDb() - // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - discardStagingFile() - return { - result: { - ok: false, - reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` - } - } - } - - // Why: staging only backs the cold-restart replay, so any failure writing it disables that - // fallback instead of aborting an import whose in-memory half still works. - let insertStmt: ChromiumImportContext['insertStmt'] = null - const context: ChromiumImportContext = { - browser, - targetPartition, - options, - targetSession, - stagingCookiesPath, - stagingAvailable, - sourceSnapshot, - sourceDb, - stagingDb, - targetColumnInfo, - colList, - placeholders, - sourceColumns, - sourceRows, - nativePlan, - plannedSourceRows, - partitionBySourceRow, - sourceKey, - imported: 0, - skipped: 0, - decryptFailed: 0, - appBoundFailed: 0, - keyringUnavailableFailed: 0, - integritySkipped: 0, - nonTransplantableSkipped: 0, - partitionSkipped: nativePlan.skips.length, - googleCookiesSkipped: 0, - memoryLoaded: 0, - memoryFailed: 0, - domainSet: new Set(), - decryptedCookies: [], - // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. - // A plan record holding only the derived fields compiles fine and then cannot stage. - scanned: [], - sourceDomainValidity: new Map(), - insertStmt, - importScope: { - exact: new Set(), - ancestors: new Set(), - descendantRoots: new Set() - }, - closeStagingDb, - discardStagingFile, - disableStaging: (reason: string): void => { - diag(` staging disabled, restart fallback unavailable: ${reason}`) - context.stagingAvailable = false - context.insertStmt = null - context.closeStagingDb() - context.discardStagingFile() - } - } satisfies ChromiumImportContext - - if (context.stagingDb && context.colList && context.placeholders) { - try { - context.insertStmt = context.stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})` + // Why (STA-4300): the partition columns drift across Chromium versions, so read the source + // schema rather than assuming a row's missing column means "unpartitioned". + const sourceColumns = new Set( + (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( + (column) => column.name ) - context.stagingDb.exec('BEGIN TRANSACTION') - } catch (err) { - context.disableStaging(String(err)) - } - } else if (context.stagingAvailable) { - context.disableStaging('staged database exposed no cookies columns') - } - // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding - // partial-import restart behavior is separate from narrowing what a staged replay may replace. - if (context.nativePlan.skippedFamilies.size > 0) { - context.disableStaging( - `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` ) + const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< + string, + unknown + >[] + sourceDb.close() + sourceDb = null + diag(` source has ${sourceRows.length} cookies`) + if (sourceRows.length === 0) { + return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } + } + + // Why (STA-4300): partition fidelity is a property of the source row, even when its value + // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. + const partitionCandidates = sourceRows.flatMap((sourceRow) => { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) + ? [] + : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] + }) + const nativePlan = planImportWrites(partitionCandidates) + const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) + const partitionBySourceRow = new Map( + partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) + ) + // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a + // family we cannot protect is the P0. Refuse before the jar is touched. + if (nativePlan.hasUnrepresentableSkip) { + return { + result: { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + } + + const needsSourceKey = sourceRows.some((sourceRow) => { + const encrypted = sourceRow.encrypted_value + if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { + return false + } + return ( + !isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) && + !isNonTransplantableCookieDomain(sourceRow.host_key as string) + ) + }) + const sourceKey = needsSourceKey + ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) + : null + if (needsSourceKey && !sourceKey) { + return { + result: { + ok: false, + reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + } + } + } + + // Why: staging only backs the cold-restart replay, so any failure writing it disables that + // fallback instead of aborting an import whose in-memory half still works. + let insertStmt: ChromiumImportContext['insertStmt'] = null + const context: ChromiumImportContext = { + browser, + targetPartition, + options, + targetSession, + stagingCookiesPath, + stagingAvailable, + sourceSnapshot, + sourceDb, + stagingDb, + targetColumnInfo, + colList, + placeholders, + sourceColumns, + sourceRows, + nativePlan, + plannedSourceRows, + partitionBySourceRow, + sourceKey, + imported: 0, + skipped: 0, + decryptFailed: 0, + appBoundFailed: 0, + keyringUnavailableFailed: 0, + integritySkipped: 0, + nonTransplantableSkipped: 0, + partitionSkipped: nativePlan.skips.length, + googleCookiesSkipped: 0, + memoryLoaded: 0, + memoryFailed: 0, + domainSet: new Set(), + decryptedCookies: [], + // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. + // A plan record holding only the derived fields compiles fine and then cannot stage. + scanned: [], + sourceDomainValidity: new Map(), + insertStmt, + importScope: { + exact: new Set(), + ancestors: new Set(), + descendantRoots: new Set() + }, + closeStagingDb, + discardStagingFile, + disableStaging: (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) + context.stagingAvailable = false + context.insertStmt = null + context.closeStagingDb() + context.discardStagingFile() + } + } satisfies ChromiumImportContext + + if (context.stagingDb && context.colList && context.placeholders) { + try { + context.insertStmt = context.stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})` + ) + context.stagingDb.exec('BEGIN TRANSACTION') + } catch (err) { + context.disableStaging(String(err)) + } + } else if (context.stagingAvailable) { + context.disableStaging('staged database exposed no cookies columns') + } + // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding + // partial-import restart behavior is separate from narrowing what a staged replay may replace. + if (context.nativePlan.skippedFamilies.size > 0) { + context.disableStaging( + `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` + ) + } + // Why: the importer owns successful preparation, including any restart fallback. + ownershipTransferred = true + return { context } + } finally { + if (!ownershipTransferred) { + try { + sourceDb?.close() + } catch { + /* best-effort */ + } + closeStagingDb() + discardStagingFile() + try { + sourceSnapshot.cleanup() + } catch (err) { + diag(` Chromium snapshot cleanup failed: ${String(err)}`) + } + } } - return { context } } diff --git a/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts b/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts new file mode 100644 index 00000000000..c704ae8c9ad --- /dev/null +++ b/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeSqlite from 'node:sqlite' +import type * as ChromiumCookieSnapshotModule from './chromium-cookie-snapshot' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { tmpdir } from 'node:os' + +const { + appGetPathMock, + sessionFromPartitionMock, + runProcessSyncMock, + snapshotRootMock, + setPendingCookieImportMock, + clearPendingCookieImportMock, + writeCookieIdentityMock, + openedDatabases, + cleanupStates +} = vi.hoisted(() => { + const openedDatabases: { path: string; database: NodeSqlite.DatabaseSync }[] = [] + const cleanupStates: boolean[][] = [] + return { + appGetPathMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + runProcessSyncMock: vi.fn(), + snapshotRootMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn(), + writeCookieIdentityMock: vi.fn(), + openedDatabases, + cleanupStates + } +}) + +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + session: { fromPartition: sessionFromPartitionMock } +})) +vi.mock('../../shared/child-process/run-process', () => ({ + runProcessSync: runProcessSyncMock +})) +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:sqlite', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + DatabaseSync: class extends actual.DatabaseSync { + constructor(...args: ConstructorParameters) { + super(...args) + openedDatabases.push({ path: String(args[0]), database: this }) + } + } + } +}) +vi.mock('./chromium-cookie-snapshot', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createChromiumCookieSnapshot: (sourcePath: string) => { + const snapshot = actual.createChromiumCookieSnapshot(sourcePath, { + tempRoot: snapshotRootMock() + }) + return { + ...snapshot, + cleanup: () => { + cleanupStates.push( + openedDatabases + .filter( + ({ path }) => + path === snapshot.databasePath || path.includes('cookie-import-staging') + ) + .map(({ database }) => database.isOpen) + ) + snapshot.cleanup() + } + } + } + } +}) +vi.mock('./browser-cookie-clear-store', () => ({ + openCookieClearStore: (targetSession: { + cookies: { get: () => Promise; remove: (url: string, name: string) => Promise } + }) => ({ + get: () => targetSession.cookies.get(), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: async () => [], + restoreClearIdentities: async () => undefined, + writeCookieIdentity: writeCookieIdentityMock, + dispose: () => undefined + }) +})) + +import { importChromiumCookies } from './browser-cookie-chromium-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { DatabaseSync } from 'node:sqlite' + +const PARTITION = 'persist:test' + +function chromeBrowser(cookiesPath: string): DetectedBrowser { + return { + family: 'chrome', + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} + +describe('Chromium import snapshot ownership', () => { + let root: string + let sourcePath: string + let targetPath: string + let snapshotRoot: string + let stagingRoot: string + let sourceDatabase: NodeSqlite.DatabaseSync + let targetBefore: Buffer + let cookiesRemoveMock: ReturnType + let platformSpy: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-cookie-snapshot-owner-test-')) + snapshotRoot = root + sourcePath = join(root, 'Chrome', 'Default', 'Network', 'Cookies') + targetPath = join(root, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + stagingRoot = join(root, 'userData', 'cookie-import-staging') + sourceDatabase = createChromiumCookieTestDatabase(sourcePath, [], { journalMode: 'wal' }) + createChromiumCookieTestDatabase(targetPath, [ + { domain: '.other.test', name: 'old', value: 'target' } + ]).close() + targetBefore = readFileSync(targetPath) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + appGetPathMock.mockReturnValue(join(root, 'userData')) + snapshotRootMock.mockReturnValue(snapshotRoot) + sessionFromPartitionMock.mockReturnValue({ + getStoragePath: () => dirname(dirname(targetPath)), + cookies: { + flushStore: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue([]), + remove: cookiesRemoveMock, + set: vi.fn().mockRejectedValue(new Error('Unexpected target initialization')) + } + }) + runProcessSyncMock.mockReturnValue({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + writeCookieIdentityMock.mockResolvedValue(undefined) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + cleanupStates.length = 0 + }) + + afterEach(() => { + for (const { database } of openedDatabases) { + if (database.isOpen) { + database.close() + } + } + openedDatabases.length = 0 + platformSpy.mockRestore() + vi.clearAllMocks() + rmSync(root, { recursive: true, force: true }) + }) + + function snapshotDirectories() { + return readdirSync(snapshotRoot).filter((name) => name.startsWith('orca-cookie-import-')) + } + + function expectRefusalCleanup() { + expect(snapshotDirectories()).toEqual([]) + expect(readdirSync(stagingRoot)).toEqual([]) + expect(readFileSync(targetPath)).toEqual(targetBefore) + expect(writeCookieIdentityMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(cleanupStates.every((states) => states.every((open) => !open))).toBe(true) + } + + function insertSourceCookie(encrypted: boolean) { + sourceDatabase.exec(`INSERT INTO cookies ( + creation_utc, host_key, name, value, encrypted_value, path, expires_utc, + is_secure, is_httponly, samesite + ) VALUES (133000000000000, '.example.test', 'sid', + ${encrypted ? "''" : "'source-value'"}, + ${encrypted ? "X'763130656E63727970746564'" : "X''"}, '/', 0, 0, 0, -1)`) + } + + it('retires an empty live-WAL source snapshot before returning its existing refusal', async () => { + expect(existsSync(`${sourcePath}-wal`)).toBe(true) + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({ + ok: false, + reason: 'No cookies found in Google Chrome.' + }) + expect(cleanupStates).toHaveLength(1) + expectRefusalCleanup() + }) + + it('retires every snapshot when denied credential access is retried', async () => { + insertSourceCookie(true) + for (let attempt = 0; attempt < 3; attempt++) { + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({ + ok: false, + reason: 'Could not access Google Chrome encryption key. The OS may have denied access.' + }) + expectRefusalCleanup() + } + expect(runProcessSyncMock).toHaveBeenCalledTimes(3) + expect(cleanupStates).toHaveLength(3) + }) + + it('retires the snapshot when an unreadable partition has no preservable family', async () => { + insertSourceCookie(false) + sourceDatabase.exec( + "UPDATE cookies SET host_key = 'com', top_frame_site_key = 'https://top.test', has_cross_site_ancestor = 2" + ) + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({ + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + }) + expect(cleanupStates).toHaveLength(1) + expectRefusalCleanup() + }) + + it('closes private databases before cleanup when source schema preparation throws', async () => { + sourceDatabase.exec('DROP TABLE cookies; CREATE TABLE other_data(value TEXT)') + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({ + ok: false, + reason: expect.stringContaining('no such table: cookies') + }) + expect(cleanupStates).toHaveLength(1) + expect(cleanupStates[0].length).toBeGreaterThanOrEqual(2) + expectRefusalCleanup() + }) + + it('hands successful preparation to the importer for ordinary completion cleanup', async () => { + insertSourceCookie(false) + const result = await importChromiumCookies(chromeBrowser(sourcePath), PARTITION) + expect(result).toMatchObject({ ok: true, summary: { totalCookies: 1, importedCookies: 1 } }) + expect(writeCookieIdentityMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'source-value' }) + ) + expect(snapshotDirectories()).toEqual([]) + expect(readdirSync(stagingRoot)).toEqual([]) + expect(cleanupStates).toEqual([[false, false]]) + }) + + it('removes the source snapshot while preserving a usable stage owned by restart replay', async () => { + insertSourceCookie(false) + writeCookieIdentityMock.mockRejectedValue(new Error('Cookie rejected')) + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toMatchObject({ + ok: true + }) + expect(snapshotDirectories()).toEqual([]) + expect(cleanupStates).toEqual([[false, false]]) + expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1) + const stagedPath: unknown = setPendingCookieImportMock.mock.calls[0][1] + if (typeof stagedPath !== 'string') { + throw new Error('No staged replay path') + } + expect(readdirSync(stagingRoot)).toEqual([basename(stagedPath)]) + const stage = new DatabaseSync(stagedPath, { readOnly: true }) + try { + const row = stage + .prepare("SELECT CAST(value AS TEXT) AS value FROM cookies WHERE name = 'sid'") + .get() + expect(row).toEqual({ value: 'source-value' }) + } finally { + stage.close() + } + }) +})