mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
* reland(browser): restore CDP-identity cookie-import writes (#14729) Reverts the revert3c8410d927(#14942) to restore the reviewedbf6dc6fcbatree. The defect that caused that revert is NOT fixed by this commit — it is fixed in the commits that follow, so the delta a reviewer must scrutinise stays small instead of hiding inside a 2000-line re-add. Conflict resolutions, all keep-both: - browser-cookie-import.ts: two hunks around the zero-import early return. #14683's undecryptable warning and decryptedCookies.length === 0 condition are kept alongside the reland's old-client gate and partitionSkippedCookies. The old-client gate stays above the early return, and so above any mutation. - en.json: both string sets (#14683's undecryptable copy and partitionSkipped). - crashpad-capture.test.ts: took HEAD wholesale; unrelated to this ticket. getStoragePath, and its positive-write assertion moves from cookies.set to the CDP identity store — adapted, not weakened, and now strictly stronger because it also asserts cookies.set carries no imported user data. Every decryption assertion is untouched. * fix(browser): add registrableFamily, one definition of a cookie family STA-4300, change 1 of the reland fixes. No behaviour change yet — this only adds the helper the later commits derive every skip scope from. Deriving "family" inline in several places is what let the removal scope and the write set disagree in STA-4090 and STA-4170, so there is exactly one. The IP check runs on normalizeCookieDomain's OUTPUT, never the raw string. psl treats an IPv4 literal as a dotted DNS name — psl.parse('127.0.0.1').domain is '0.1' — and Chromium accepts many spellings of one address. new URL() inside normalizeCookieDomain canonicalises 127.1 / 2130706433 / 0x7f.1 / 010.0.0.1 and a trailing dot to a dotted quad first, so isIP() then catches all of them. Mutation-proved: moving the check ahead of normalisation reddens the five alternate-spelling cases and nothing else. Bracketed IPv6 needs its own branch because isIP('[::1]') is 0; without it the value falls through to psl, which throws, which returns the host — the right answer for the wrong reason. Returns null for a bare public suffix: naming `com` as a family would preserve an entire TLD from removal and silently turn an import into a no-op. * fix(browser): plan every cookie's fate before any jar mutation STA-4300, change 2. Adds planImportWrites — pure, no I/O — so the write set and every removal scope can derive from one value instead of being computed twice. Not yet wired into the import paths; that is the next commits. TWO passes, and the second is not optional. Family-atomic skip is a property of the whole input: with a readable mixed.example row BEFORE an unreadable sub.mixed.example row, a per-row guard emits the readable one before anything knows the family will be skipped. Pass 1 classifies and collects the skipped families; pass 2 re-filters the provisional writes. Mutation-proved with a faithful one-pass guard (consult skippedFamilies as you go): the readable-before-unreadable case goes red and the unreadable-before- readable case still passes. That asymmetry is why both orders are tested and why only the first is the named detector. Family closure is at the registrable boundary because that is the boundary the removal scope actually expands to: importedDomainScopes turns an imported mixed.example into descendant roots, so a readable apex cookie drags a skipped subdomain's live session into the removal scope (STA-4300 §2b). hasUnrepresentableSkip surfaces a skip whose family cannot be named. A family we cannot name is one we cannot exclude from the removal plan, and clearing a family we cannot protect is the P0 this ticket exists to stop — so the caller refuses before mutating rather than proceeding and hoping. * fix(browser): derive path A's removal scope from the write plan (STA-4300 §2b) Change 3. importValidatedCookies now plans before it opens the jar, and the replacement scope and the write set are the SAME array.bf6dc6fcbafiltered replacementDomains per exact cookie (partition.status !== 'unreadable'). That is not sufficient: replaceCookiesForImportedDomains expands each imported domain into its descendant roots, so a readable cookie on mixed.example pulls sub.mixed.example into the removal scope — and a skipped cookie living there has its session removed with nothing written back. Same erasure as the P0, different path. plan.writes is family-closed, and because path A passes the very same array to replaceCookiesForImportedDomains and to writeImportedCookies, the two sets cannot drift apart. That is stronger than keeping them in sync: there is only one set. Also here, both before any mutation: - an unrepresentable skip (registrableFamily → null) refuses the import, because a family we cannot name is one we cannot exclude from the removal scope; - the old-client gate now keys off plan.skips rather than re-deriving the condition. Counters: partitionSkippedCookies is a BREAKDOWN of skippedCookies — the unreadable rows plus their family-suppressed siblings — added in exactly once, so totalCookies === importedCookies + skippedCookies keeps holding. Counting it separately is how a summary silently stops adding up. Full src/main/browser suite: 69 files, 729 tests, green. * fix(browser): split path B into scan and emit, and never stage a skip (STA-4300) Change 4 — this is the commit that fixes the shipped P0.bf6dc6fcbadid: decryptedCookies.push({ ..., partition }) // write set built HERE if (partition.status === 'unreadable') { ... continue } // guard AFTER so an unreadable row discovered late could not retract a sibling already emitted, while removeTransplantableCookies cleared the ENTIRE jar. The mutation set was the whole jar and the write set a strict subset of it, which is how a mixed readable/unreadable source emptied a populated jar and repopulated only part of it. Now the row loop SCANS only. Nothing is emitted inside it — not decryptedCookies, not domainSet, not a staging row, not the imported count. Between scan and emit, planImportWrites closes the skip over whole registrable families, and emit walks the plan. Each scanned candidate carries its raw source row because buildChromiumCookieInsertParams needs it; a record holding only the derived fields compiles and then silently cannot stage. Also between scan and emit, both before any mutation: - an unrepresentable skip refuses the import; - any skipped family calls disableStaging. A staged image is a whole-database replacement on the next start, so it cannot express "preserve this family"; main's existing memoryFailed arm then reports restart-fallback-unavailable. Test contract change, deliberately stronger: "never stages a partitioned row whose ancestor bit is unreadable" asserted that an image WAS registered and merely omitted the row. That image would still have erased the preserved family on replay. It now asserts no image is registered at all — plus a new paired case proving a no-skip import still stages, so "disable on skip" cannot be implemented as "disable always" unnoticed. Honest note on detection: reverting the emit filter currently reddens only the staging assertion, because every end-to-end fixture in this module still starts with an EMPTY jar — where clear-then-write-all and clear-then-write-some are indistinguishable. That is the blind spot that let this ship. The populated-jar suite in the next commit is what actually detects the erasure. Full src/main/browser: 69 files, 730 tests, green. * fix(browser): never remove a family the import declined to write (STA-4300 I2) Change 5, and the one that makes preservation observable. removeTransplantableCookies takes preserveFamilies. removableCookieEntries filters those families out, which keeps them out of the removal plan AND — since the CDP snapshot is taken from that same list — out of the restore set, so a preserved coordinate is never submitted to any mutation at all. When anything is preserved, the bulk clearData path is not used. clearData removes everything outside excludeOrigins, and its own comment concedes a rejection may already have emptied part of the jar; a preserved family is deliberately absent from the snapshot, so a partial delete followed by a rejection would destroy it with no identity able to restore it. Handing a dynamically derived preserve list to that primitive cannot be made safe, so a skip-bearing clear runs the frozen per-coordinate plan — which already excludes those families — as its primary path instead. Nothing is preserved on an ordinary import, so that path keeps today's single clearData call unchanged. New populated-jar suite. Every end-to-end fixture in this module starts with cookies.get returning [], and against an empty jar "clear then write all" and "clear then write some" are indistinguishable — which is why four independent gates missed the P0. These fixtures start populated. Mutation-proved, and this is the first detector in the change set that catches the actual erasure rather than a proxy: - drop the preserved-family filter → 6 of 8 red - use bulk clearData while preserving → 6 of 8 red, including "leaves a preserved family untouched", which is the erasure itself IPv4-literal and single-label host families are covered explicitly: psl reads 127.0.0.1 as the dotted DNS name '0.1', so a wrong family here would fail to match the preserve set and erase the live loopback session. Full src/main/browser: 70 files, 738 tests, green. * test(browser): pin STA-4300 end to end in real Electron, both write paths Ports the pre-implementation reproduction into the PR. Verified RED against main's product code and GREEN with the fix, on both paths — I re-ran it both ways rather than assuming. Two assertions are INVERTED rather than deleted, and both are now stronger. The repro proved the defect by showing cookies.set was called WITHOUT a partitionKey; after the reland the import write path does not touch cookies.set for user data at all, so seeing the imported cookie there is itself the regression. The fixture's internal guard is inverted the same way. Non-vacuity, asserted in-run rather than in a report: ok:true with a nonzero importedCookies, the importer reaching its terminal step, the source really carrying its partition fields, and a CONTROL cookie written via CDP *with* a partitionKey and read back with it — so "partitionKey absent" cannot be confused with "the oracle cannot see partitions". electronVersion proves Electron ran. Fixture correction worth recording, because the original reproduction was invalid on this path and I did not catch it on first read: readJsonCookiePartition reads a NESTED partitionKey object, but the fixture wrote topLevelSite and hasCrossSiteAncestor at the entry's top level, where the importer never looks. The file/paste case therefore failed on main for a fixture-shape reason rather than for the defect — and its own non-vacuity check validated what the fixture wrote instead of what the parser reads, which is exactly how a check that looks rigorous proves nothing. Only the native half was ever a real reproduction. Partition keys use a schemeful SITE, not an origin: Chromium canonicalises https://app.example.com to https://example.com, measured via CDP. * fix(browser): fail closed on lossy cookie identity recovery * fix(browser): preserve unreadable families before decrypt * fix(browser): reject malformed cookie partition sites * fix(browser): validate recovered cookie partition sites * fix(browser): reject empty JSON partition identities * fix(browser): fail closed and serialize cookie imports * fix(browser): reject malformed CDP opaque flags * fix(browser): roll back outcome-unknown cookie writes * revert(browser): move import serialization out to STA-4601 Reverts only the concurrency-serialization half off7c27b71abso #15030 stays scoped to the STA-4300 reland. That commit mixed two concerns; this removes one of them and keeps the other. REMOVED (moves to STA-4601, a separate pre-existing P1): - acquireCookieMutationLock / withCookieMutationLock and the mutationLocks map; clear.ts goes back to withCookieClearLock - the import-wide lock in path A (mutationOwner on the target, acquire/release around replace + writes + rollback) - the widened lock around path B's clear + memory writes - browser-cookie-import-concurrency.test.ts KEPT (genuinely STA-4300 scope, not concurrency): - partitionKeyOpaque handling in readJsonCookiePartition and the CDP snapshot. An opaque partition key cannot be represented faithfully, so it reads as unreadable and its family is preserved — exactly the rule this ticket adds. 194e57a628's refinement of that shape stays too. The concurrent-import interleaving is real and reachable (nothing serialises imports per partition, and the clear lock is released before the memory writes), but it predates this change and is not caused or worsened by it. It gets its own PR and its own review rather than riding a P0 reland whose history is that every additional fix introduced a new defect. src/main/browser: 71 files, 772 tests, green. Electron oracle still passes on both write paths and still goes red against main's product code.
910 lines
33 KiB
TypeScript
910 lines
33 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type * as NodeFs from 'node:fs'
|
|
|
|
const {
|
|
appGetPathMock,
|
|
copyFileSyncMock,
|
|
execFileSyncMock,
|
|
sessionFromPartitionMock,
|
|
dialogShowOpenDialogMock,
|
|
setPendingCookieImportMock,
|
|
clearPendingCookieImportMock,
|
|
writeCookieIdentityMock
|
|
} = vi.hoisted(() => ({
|
|
appGetPathMock: vi.fn(),
|
|
copyFileSyncMock: vi.fn(),
|
|
execFileSyncMock: vi.fn(),
|
|
sessionFromPartitionMock: vi.fn(),
|
|
dialogShowOpenDialogMock: vi.fn(),
|
|
setPendingCookieImportMock: vi.fn(),
|
|
clearPendingCookieImportMock: vi.fn(),
|
|
writeCookieIdentityMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./browser-session-registry', () => ({
|
|
browserSessionRegistry: {
|
|
setPendingCookieImport: setPendingCookieImportMock,
|
|
clearPendingCookieImport: clearPendingCookieImportMock
|
|
}
|
|
}))
|
|
|
|
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof NodeFs>()
|
|
return {
|
|
...actual,
|
|
copyFileSync: (...args: Parameters<typeof actual.copyFileSync>) => {
|
|
copyFileSyncMock(...args)
|
|
return actual.copyFileSync(...args)
|
|
}
|
|
}
|
|
})
|
|
|
|
vi.mock('electron', () => ({
|
|
app: { getPath: appGetPathMock },
|
|
BrowserWindow: { fromWebContents: vi.fn() },
|
|
dialog: { showOpenDialog: dialogShowOpenDialogMock },
|
|
session: { fromPartition: sessionFromPartitionMock }
|
|
}))
|
|
vi.mock('./browser-cookie-clear-store', () => ({
|
|
openCookieClearStore: (targetSession: {
|
|
cookies: {
|
|
get: (filter: object) => Promise<unknown>
|
|
remove: (url: string, name: string) => Promise<void>
|
|
}
|
|
}) => ({
|
|
get: (filter: object) => targetSession.cookies.get(filter),
|
|
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
|
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
|
items.map(({ cookie, url }) => ({ url, ...cookie })),
|
|
restoreClearIdentities: async () => undefined,
|
|
// Why (STA-4300): the import writes land here, not on cookies.set. A store mock missing this
|
|
// method would throw a TypeError the per-cookie catch swallows, quietly turning every write
|
|
// into a "rejected cookie" while the suite still looked green.
|
|
writeCookieIdentity: writeCookieIdentityMock,
|
|
dispose: () => undefined
|
|
})
|
|
}))
|
|
|
|
import {
|
|
buildChromiumCookieInsertParams,
|
|
importCookiesFromFile,
|
|
importCookiesFromBrowser,
|
|
detectInstalledBrowsers,
|
|
summarizeCookieImportError,
|
|
type ChromiumCookieColumnInfo,
|
|
type DetectedBrowser
|
|
} from './browser-cookie-import'
|
|
import {
|
|
createChromiumCookieTestDatabase,
|
|
encryptMacChromiumCookie
|
|
} from './browser-cookie-import-test-database'
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync
|
|
} from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
|
|
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'
|
|
}
|
|
}
|
|
|
|
// Why (STA-4300): cookies.set() silently drops partitionKey, so no user cookie may reach it. Only
|
|
// the __init probe — which writes no user data — is allowed through; anything else is the
|
|
// downgrade returning, and it must fail the test rather than quietly succeed.
|
|
const unreachableCookieSet = vi.fn(async (details: { name: string }) => {
|
|
if (details.name !== '__init') {
|
|
throw new Error(`cookies.set was called for user cookie ${details.name}`)
|
|
}
|
|
})
|
|
|
|
const LARGE_SAFARI_COOKIE_COUNT = 150_000
|
|
|
|
describe('summarizeCookieImportError', () => {
|
|
it('folds a bounded error preview without full-string whitespace replacement', () => {
|
|
const replaceSpy = vi.spyOn(String.prototype, 'replace')
|
|
const message = `Import failed\n\t${'secret-cookie-value '.repeat(20_000)}`
|
|
|
|
const summary = summarizeCookieImportError(new Error(message))
|
|
|
|
expect(summary.length).toBeLessThanOrEqual(180)
|
|
expect(summary).toContain('Import failed secret-cookie-value')
|
|
expect(replaceSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
function buildSafariBinaryCookies(cookieCount: number): Buffer {
|
|
const cookies: Buffer[] = []
|
|
const offsets: number[] = []
|
|
let pageSize = 8 + cookieCount * 4
|
|
|
|
for (let index = 0; index < cookieCount; index += 1) {
|
|
offsets.push(pageSize)
|
|
const cookie = buildExpiredSafariCookie(index)
|
|
cookies.push(cookie)
|
|
pageSize += cookie.length
|
|
}
|
|
|
|
const page = Buffer.alloc(pageSize)
|
|
page.writeUInt32BE(0x00000100, 0)
|
|
page.writeUInt32LE(cookieCount, 4)
|
|
for (let index = 0; index < offsets.length; index += 1) {
|
|
page.writeUInt32LE(offsets[index], 8 + index * 4)
|
|
}
|
|
|
|
let cookieOffset = 8 + cookieCount * 4
|
|
for (const cookie of cookies) {
|
|
cookie.copy(page, cookieOffset)
|
|
cookieOffset += cookie.length
|
|
}
|
|
|
|
const file = Buffer.alloc(12 + page.length)
|
|
file.write('cook', 0, 'utf8')
|
|
file.writeUInt32BE(1, 4)
|
|
file.writeUInt32BE(page.length, 8)
|
|
page.copy(file, 12)
|
|
return file
|
|
}
|
|
|
|
function buildExpiredSafariCookie(index: number): Buffer {
|
|
const domain = `.expired-${index}.example.com`
|
|
const name = `sid-${index}`
|
|
const path = '/'
|
|
const value = 'expired'
|
|
const strings = [domain, name, path, value]
|
|
const headerSize = 48
|
|
let cursor = headerSize
|
|
const offsets = strings.map((text) => {
|
|
const offset = cursor
|
|
cursor += Buffer.byteLength(text) + 1
|
|
return offset
|
|
})
|
|
|
|
const cookie = Buffer.alloc(cursor)
|
|
cookie.writeUInt32LE(cookie.length, 0)
|
|
cookie.writeUInt32LE(offsets[0], 16)
|
|
cookie.writeUInt32LE(offsets[1], 20)
|
|
cookie.writeUInt32LE(offsets[2], 24)
|
|
cookie.writeUInt32LE(offsets[3], 28)
|
|
cookie.writeDoubleLE(1, 40)
|
|
for (let index = 0; index < strings.length; index += 1) {
|
|
cookie.write(strings[index], offsets[index], 'utf8')
|
|
}
|
|
return cookie
|
|
}
|
|
|
|
describe('importCookiesFromFile', () => {
|
|
let tmpDir: string
|
|
let cookiesGetMock: ReturnType<typeof vi.fn>
|
|
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
|
let cookieWriteMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-'))
|
|
cookiesGetMock = vi.fn().mockResolvedValue([])
|
|
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
|
cookieWriteMock = writeCookieIdentityMock
|
|
cookieWriteMock.mockReset()
|
|
cookieWriteMock.mockResolvedValue(undefined)
|
|
sessionFromPartitionMock.mockReset()
|
|
sessionFromPartitionMock.mockReturnValue({
|
|
cookies: {
|
|
get: cookiesGetMock,
|
|
remove: cookiesRemoveMock,
|
|
set: unreachableCookieSet
|
|
}
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
function writeCookieFile(cookies: unknown[]): string {
|
|
const filePath = join(tmpDir, 'cookies.json')
|
|
writeFileSync(filePath, JSON.stringify(cookies))
|
|
return filePath
|
|
}
|
|
|
|
it('imports valid cookies', async () => {
|
|
const filePath = writeCookieFile([
|
|
{
|
|
domain: '.github.com',
|
|
name: '_gh_sess',
|
|
value: 'abc123',
|
|
path: '/',
|
|
secure: true,
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
expirationDate: 1800000000
|
|
},
|
|
{
|
|
domain: '.example.com',
|
|
name: 'test',
|
|
value: 'val',
|
|
path: '/',
|
|
secure: false,
|
|
httpOnly: false
|
|
}
|
|
])
|
|
|
|
const result = await importCookiesFromFile(filePath, 'persist:test')
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) {
|
|
return
|
|
}
|
|
|
|
expect(result.summary.totalCookies).toBe(2)
|
|
expect(result.summary.importedCookies).toBe(2)
|
|
expect(result.summary.skippedCookies).toBe(0)
|
|
expect(result.summary.domains).toContain('github.com')
|
|
expect(result.summary.domains).toContain('example.com')
|
|
|
|
expect(cookieWriteMock).toHaveBeenCalledTimes(2)
|
|
const firstCall = cookieWriteMock.mock.calls[0][0]
|
|
expect(firstCall.name).toBe('_gh_sess')
|
|
expect(firstCall.domain).toBe('.github.com')
|
|
expect(firstCall.secure).toBe(true)
|
|
expect(firstCall.sameSite).toBe('lax')
|
|
})
|
|
|
|
it('sets __Host- cookies host-only so Chromium does not reject them', async () => {
|
|
const filePath = writeCookieFile([
|
|
{
|
|
domain: 'github.com',
|
|
name: '__Host-user_session_same_site',
|
|
value: 'sess',
|
|
path: '/account',
|
|
secure: true,
|
|
httpOnly: true,
|
|
sameSite: 'lax'
|
|
},
|
|
{ domain: '.github.com', name: '_gh_sess', value: 'abc', path: '/settings', secure: true }
|
|
])
|
|
|
|
const result = await importCookiesFromFile(filePath, 'persist:test')
|
|
expect(result.ok).toBe(true)
|
|
|
|
const hostCall = cookieWriteMock.mock.calls
|
|
.map((c) => c[0])
|
|
.find((c) => c.name === '__Host-user_session_same_site')
|
|
// __Host- prefix requires no Domain attribute and path=/, or Chromium drops it. hostOnly is how
|
|
// the identity says "omit domain"; cdpSetCookieParamsFromIdentity drops it on the wire.
|
|
expect(hostCall.hostOnly).toBe(true)
|
|
expect(hostCall.path).toBe('/')
|
|
|
|
const normalCall = cookieWriteMock.mock.calls
|
|
.map((c) => c[0])
|
|
.find((c) => c.name === '_gh_sess')
|
|
expect(normalCall.hostOnly).toBe(false)
|
|
expect(normalCall.domain).toBe('.github.com')
|
|
expect(normalCall.path).toBe('/settings')
|
|
})
|
|
|
|
it('rejects non-JSON files', async () => {
|
|
const filePath = join(tmpDir, 'bad.json')
|
|
writeFileSync(filePath, 'not json at all')
|
|
|
|
const result = await importCookiesFromFile(filePath, 'persist:test')
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) {
|
|
return
|
|
}
|
|
expect(result.reason).toContain('not valid JSON')
|
|
})
|
|
|
|
it('rejects non-array JSON', async () => {
|
|
const filePath = join(tmpDir, 'object.json')
|
|
writeFileSync(filePath, '{"domain": "test.com"}')
|
|
|
|
const result = await importCookiesFromFile(filePath, 'persist:test')
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) {
|
|
return
|
|
}
|
|
expect(result.reason).toContain('JSON array')
|
|
})
|
|
|
|
it('rejects empty array', async () => {
|
|
const filePath = writeCookieFile([])
|
|
const result = await importCookiesFromFile(filePath, 'persist:test')
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) {
|
|
return
|
|
}
|
|
expect(result.reason).toContain('empty')
|
|
})
|
|
|
|
it('skips entries with missing required fields', async () => {
|
|
const filePath = writeCookieFile([
|
|
{ domain: '.valid.com', name: 'ok', value: 'val' },
|
|
{ name: 'no-domain', value: 'val' },
|
|
{ domain: '.valid2.com', value: 'no-name' },
|
|
{ domain: '.valid3.com', name: 'no-value' },
|
|
'not an object',
|
|
42
|
|
])
|
|
|
|
const result = await importCookiesFromFile(filePath, 'persist:test')
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) {
|
|
return
|
|
}
|
|
|
|
expect(result.summary.importedCookies).toBe(1)
|
|
expect(result.summary.skippedCookies).toBe(5)
|
|
})
|
|
|
|
it('reports all skipped when no valid cookies', async () => {
|
|
const filePath = writeCookieFile([
|
|
{ name: 'no-domain', value: 'val' },
|
|
{ domain: '', name: 'empty-domain', value: 'val' }
|
|
])
|
|
|
|
const result = await importCookiesFromFile(filePath, 'persist:test')
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) {
|
|
return
|
|
}
|
|
expect(result.reason).toContain('No valid cookies')
|
|
expect(result.reason).toContain('2 entries were skipped')
|
|
})
|
|
|
|
it('handles file read errors', async () => {
|
|
const result = await importCookiesFromFile('/nonexistent/path.json', 'persist:test')
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) {
|
|
return
|
|
}
|
|
expect(result.reason).toContain('Could not read')
|
|
})
|
|
|
|
it('normalizes sameSite values', async () => {
|
|
const filePath = writeCookieFile([
|
|
{ domain: '.test.com', name: 'a', value: '1', sameSite: 'None' },
|
|
{ domain: '.test.com', name: 'b', value: '2', sameSite: 'Lax' },
|
|
{ domain: '.test.com', name: 'c', value: '3', sameSite: 'Strict' },
|
|
{ domain: '.test.com', name: 'd', value: '4', sameSite: 'unknown' },
|
|
{ domain: '.test.com', name: 'e', value: '5' }
|
|
])
|
|
|
|
await importCookiesFromFile(filePath, 'persist:test')
|
|
|
|
expect(cookieWriteMock.mock.calls[0][0].sameSite).toBe('no_restriction')
|
|
expect(cookieWriteMock.mock.calls[1][0].sameSite).toBe('lax')
|
|
expect(cookieWriteMock.mock.calls[2][0].sameSite).toBe('strict')
|
|
expect(cookieWriteMock.mock.calls[3][0].sameSite).toBe('unspecified')
|
|
expect(cookieWriteMock.mock.calls[4][0].sameSite).toBe('unspecified')
|
|
})
|
|
|
|
it('derives correct URL from domain and secure flag', async () => {
|
|
const filePath = writeCookieFile([
|
|
{ domain: '.secure.com', name: 'a', value: '1', secure: true },
|
|
{ domain: '.insecure.com', name: 'b', value: '2', secure: false },
|
|
{ domain: 'nodot.com', name: 'c', value: '3' }
|
|
])
|
|
|
|
await importCookiesFromFile(filePath, 'persist:test')
|
|
|
|
expect(cookieWriteMock.mock.calls[0][0].url).toBe('https://secure.com/')
|
|
expect(cookieWriteMock.mock.calls[1][0].url).toBe('http://insecure.com/')
|
|
expect(cookieWriteMock.mock.calls[2][0].url).toBe('http://nodot.com/')
|
|
})
|
|
|
|
it('rolls back replacement when a cookie fails to set', async () => {
|
|
cookieWriteMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed'))
|
|
|
|
const filePath = writeCookieFile([
|
|
{ domain: '.a.com', name: 'ok', value: '1' },
|
|
{ domain: '.b.com', name: 'fail', value: '2' }
|
|
])
|
|
|
|
const result = await importCookiesFromFile(filePath, 'persist:test')
|
|
expect(result.ok).toBe(false)
|
|
expect(cookiesRemoveMock).toHaveBeenCalledWith('http://a.com/', 'ok')
|
|
})
|
|
})
|
|
|
|
describe('importCookiesFromBrowser Safari', () => {
|
|
let tmpDir: string
|
|
let cookieWriteMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'orca-safari-cookie-test-'))
|
|
cookieWriteMock = writeCookieIdentityMock
|
|
cookieWriteMock.mockReset()
|
|
cookieWriteMock.mockResolvedValue(undefined)
|
|
sessionFromPartitionMock.mockReset()
|
|
sessionFromPartitionMock.mockReturnValue({
|
|
cookies: { set: unreachableCookieSet }
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('reports expired cookies from large Safari binary cookie pages', async () => {
|
|
const cookiesPath = join(tmpDir, 'Cookies.binarycookies')
|
|
writeFileSync(cookiesPath, buildSafariBinaryCookies(LARGE_SAFARI_COOKIE_COUNT))
|
|
const browser: DetectedBrowser = {
|
|
family: 'safari',
|
|
label: 'Safari',
|
|
cookiesPath,
|
|
profiles: [],
|
|
selectedProfile: 'Default'
|
|
}
|
|
|
|
const result = await importCookiesFromBrowser(browser, 'persist:test')
|
|
|
|
expect(result).toEqual({ ok: false, reason: 'All Safari cookies are expired.' })
|
|
expect(cookieWriteMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('importCookiesFromBrowser Chromium', () => {
|
|
let tmpDir: string
|
|
let cookieWriteMock: ReturnType<typeof vi.fn>
|
|
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
|
let cookiesFlushStoreMock: ReturnType<typeof vi.fn>
|
|
let clearDataMock: ReturnType<typeof vi.fn>
|
|
let setUserAgentMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-'))
|
|
cookieWriteMock = writeCookieIdentityMock
|
|
cookieWriteMock.mockReset()
|
|
cookieWriteMock.mockResolvedValue(undefined)
|
|
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
|
cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined)
|
|
clearDataMock = vi.fn().mockResolvedValue(undefined)
|
|
setUserAgentMock = vi.fn()
|
|
appGetPathMock.mockReset()
|
|
appGetPathMock.mockReturnValue(join(tmpDir, 'userData'))
|
|
copyFileSyncMock.mockClear()
|
|
setPendingCookieImportMock.mockClear()
|
|
clearPendingCookieImportMock.mockClear()
|
|
execFileSyncMock.mockReset()
|
|
execFileSyncMock.mockImplementation(() => {
|
|
throw new Error('OS credential commands are unavailable in this test')
|
|
})
|
|
sessionFromPartitionMock.mockReset()
|
|
sessionFromPartitionMock.mockReturnValue({
|
|
cookies: {
|
|
get: vi.fn().mockResolvedValue([]),
|
|
set: unreachableCookieSet,
|
|
remove: cookiesRemoveMock,
|
|
flushStore: cookiesFlushStoreMock
|
|
},
|
|
clearData: clearDataMock,
|
|
setUserAgent: setUserAgentMock,
|
|
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
it('imports from a live Chromium source DB into a Network/Cookies target profile', async () => {
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
// Why: keeping the writer open leaves the committed row in WAL, matching a
|
|
// running Chromium profile whose latest auth cookies are not checkpointed.
|
|
const sourceDb = createChromiumCookieTestDatabase(
|
|
sourceCookiesPath,
|
|
[{ name: 'sid', value: 'source-value' }],
|
|
{ journalMode: 'wal' }
|
|
)
|
|
createChromiumCookieTestDatabase(targetCookiesPath, [
|
|
{ name: 'old', value: 'target-value' }
|
|
]).close()
|
|
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
execFileSyncMock.mockImplementation((command: string) => {
|
|
if (command === 'defaults') {
|
|
return '120.0.6099.71\n'
|
|
}
|
|
throw new Error(`Unexpected command: ${command}`)
|
|
})
|
|
try {
|
|
expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true)
|
|
const sourceFilesBefore = ['', '-wal', '-shm'].map((suffix) =>
|
|
readFileSync(sourceCookiesPath + suffix)
|
|
)
|
|
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(cookieWriteMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
domain: '.example.com',
|
|
name: 'sid',
|
|
value: 'source-value'
|
|
})
|
|
)
|
|
expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false)
|
|
expect(execFileSyncMock.mock.calls.some(([command]) => command === 'defaults')).toBe(false)
|
|
expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe(
|
|
true
|
|
)
|
|
expect(
|
|
copyFileSyncMock.mock.calls.some(([source]) => source === `${sourceCookiesPath}-wal`)
|
|
).toBe(true)
|
|
expect(
|
|
['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix))
|
|
).toEqual(sourceFilesBefore)
|
|
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
|
expect(clearDataMock).not.toHaveBeenCalled()
|
|
// Why: STA-3514 — imports must never impersonate the source browser; the
|
|
// session keeps the engine UA the registry set at startup.
|
|
expect(setUserAgentMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
sourceDb.close()
|
|
}
|
|
})
|
|
|
|
it('uses the OS key for encrypted Chromium rows', async () => {
|
|
const password = 'test-password'
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
|
{
|
|
name: 'sid',
|
|
value: '',
|
|
encryptedValue: encryptMacChromiumCookie('encrypted-value', password)
|
|
}
|
|
]).close()
|
|
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
|
execFileSyncMock.mockImplementation((command: string) => {
|
|
if (command === 'security') {
|
|
return `${password}\n`
|
|
}
|
|
throw new Error(`Unexpected command: ${command}`)
|
|
})
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
|
|
try {
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(execFileSyncMock).toHaveBeenCalledWith(
|
|
'security',
|
|
expect.any(Array),
|
|
expect.any(Object)
|
|
)
|
|
expect(cookieWriteMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'sid', value: 'encrypted-value' })
|
|
)
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('removes staging data when the OS key is unavailable', async () => {
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
|
{ name: 'sid', value: '', encryptedValue: Buffer.from('v10-encrypted') }
|
|
]).close()
|
|
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
|
|
try {
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(false)
|
|
expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([])
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
// Why: #9355 — staging only backs the cold-restart replay, so an AV/EDR handle that blocks
|
|
// the staging copy must degrade that fallback, not abort an import the memory path can serve.
|
|
it('still imports in-memory when the target database copy fails', async () => {
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
|
{ name: 'sid', value: 'source-value' }
|
|
]).close()
|
|
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
|
// The staging copy runs before the source snapshot, so the first copy is the staging one.
|
|
copyFileSyncMock.mockImplementationOnce((_source: string, destination: string) => {
|
|
writeFileSync(destination, 'partial cookie database')
|
|
const error = new Error('EBUSY: resource busy or locked, copyfile') as NodeJS.ErrnoException
|
|
error.code = 'EBUSY'
|
|
throw error
|
|
})
|
|
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
try {
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(cookieWriteMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
|
)
|
|
// The partial staging file is still discarded, so no stale DB replays on cold start.
|
|
expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([])
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
// Why: #9355 — the staged file is also named "Cookies", so the same transient handle can make
|
|
// opening it throw. That was fatal too, and the count must stay truthful without a staging DB.
|
|
it('still imports in-memory when the staging database cannot be opened', async () => {
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
|
{ name: 'sid', value: 'source-value' }
|
|
]).close()
|
|
mkdirSync(dirname(targetCookiesPath), { recursive: true })
|
|
// A live partition DB that copies fine but is not openable as SQLite.
|
|
writeFileSync(targetCookiesPath, 'not a sqlite database')
|
|
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
try {
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(cookieWriteMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
|
)
|
|
// The summary counts importable cookies, not staged rows.
|
|
expect(result.ok && result.summary?.importedCookies).toBe(1)
|
|
expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([])
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
// Why: #9355 — registering a staged path that was never written would make the next cold start
|
|
// replay a missing or partial DB over the live partition.
|
|
it('never registers a restart replay when staging is unavailable', async () => {
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
|
{ name: 'sid', value: 'source-value' }
|
|
]).close()
|
|
mkdirSync(dirname(targetCookiesPath), { recursive: true })
|
|
writeFileSync(targetCookiesPath, 'not a sqlite database')
|
|
// Forces the restart fallback to be the only way these cookies could ever land.
|
|
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
|
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
try {
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
|
// An older staged DB must not survive an import that already rewrote the live session.
|
|
expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test')
|
|
expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([])
|
|
// Why: the jar was cleared and nothing replaced it, so this must not read as a clean success.
|
|
expect(result.ok && result.summary?.warning).toEqual({
|
|
code: 'restart-fallback-unavailable',
|
|
loadedCookies: 0,
|
|
failedCookies: 1
|
|
})
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
// Why: #9355 — a staging write that fails mid-transaction must disable the restart fallback
|
|
// rather than abort an import whose in-memory half still works.
|
|
it('still imports in-memory when a staging insert fails', async () => {
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
|
{ name: 'sid', value: 'source-value' }
|
|
]).close()
|
|
const targetDb = createChromiumCookieTestDatabase(targetCookiesPath, [])
|
|
// Rejects every staged row the way a corrupt index or disk error would.
|
|
targetDb.exec(
|
|
`CREATE TRIGGER reject_insert BEFORE INSERT ON cookies
|
|
BEGIN SELECT RAISE(ABORT, 'staging write failed'); END`
|
|
)
|
|
targetDb.close()
|
|
// Why: without a memory failure, memoryFailed === 0 would suppress registration on its own and
|
|
// the assertion below would pass even if the insert failure never disabled staging.
|
|
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
|
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
try {
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(cookieWriteMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
|
)
|
|
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
|
expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test')
|
|
expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([])
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
// Why: #9355 — a successful in-memory import must retire any older staged replay, or the next
|
|
// cold start will overwrite the live session with a stale snapshot.
|
|
it('clears a stale staged replay after an import that fully succeeds in memory', async () => {
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
|
{ name: 'sid', value: 'source-value' }
|
|
]).close()
|
|
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
|
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
try {
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
|
expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test')
|
|
// A fully in-memory import needs no restart, so it must not warn.
|
|
expect(result.ok && result.summary?.warning).toBeUndefined()
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
// Why: the staged path is the restart fallback's only input, so a working staging DB must register it.
|
|
it('registers the staged database when cookies need a restart', async () => {
|
|
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
|
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
|
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
|
{ name: 'sid', value: 'source-value' }
|
|
]).close()
|
|
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
|
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
|
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
try {
|
|
const result = await importCookiesFromBrowser(
|
|
chromeBrowser(sourceCookiesPath),
|
|
'persist:test'
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1)
|
|
const [partition, stagedPath] = setPendingCookieImportMock.mock.calls[0]
|
|
expect(partition).toBe('persist:test')
|
|
expect(existsSync(stagedPath as string)).toBe(true)
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('detectInstalledBrowsers', () => {
|
|
it('returns an array of detected browsers', () => {
|
|
const browsers = detectInstalledBrowsers()
|
|
expect(Array.isArray(browsers)).toBe(true)
|
|
for (const browser of browsers) {
|
|
expect(browser).toHaveProperty('family')
|
|
expect(browser).toHaveProperty('label')
|
|
expect(browser).toHaveProperty('cookiesPath')
|
|
// keychainService/keychainAccount are only present for Chromium-based browsers
|
|
if (['chrome', 'edge', 'arc', 'chromium'].includes(browser.family)) {
|
|
expect(browser).toHaveProperty('keychainService')
|
|
expect(browser).toHaveProperty('keychainAccount')
|
|
}
|
|
}
|
|
})
|
|
|
|
it('each detected browser has a valid family', () => {
|
|
const browsers = detectInstalledBrowsers()
|
|
const validFamilies = [
|
|
'chrome',
|
|
'edge',
|
|
'arc',
|
|
'chromium',
|
|
'firefox',
|
|
'safari',
|
|
'comet',
|
|
'helium'
|
|
]
|
|
for (const browser of browsers) {
|
|
expect(validFamilies).toContain(browser.family)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('buildChromiumCookieInsertParams', () => {
|
|
it('fills target-only NOT NULL Chromium cookie columns instead of inserting null', () => {
|
|
const decryptedValue = Buffer.from('decrypted-cookie-value')
|
|
const columns: ChromiumCookieColumnInfo[] = [
|
|
{ name: 'creation_utc', type: 'INTEGER', notnull: 1 },
|
|
{ name: 'host_key', type: 'TEXT', notnull: 1 },
|
|
{ name: 'top_frame_site_key', type: 'TEXT', notnull: 1 },
|
|
{ name: 'name', type: 'TEXT', notnull: 1 },
|
|
{ name: 'value', type: 'TEXT', notnull: 1 },
|
|
{ name: 'encrypted_value', type: 'BLOB', notnull: 1 },
|
|
{ name: 'source_port', type: 'INTEGER', notnull: 1 },
|
|
{ name: 'last_update_utc', type: 'INTEGER', notnull: 1 },
|
|
{ name: 'has_cross_site_ancestor', type: 'INTEGER', notnull: 1, dflt_value: '0' }
|
|
]
|
|
const sourceRow = {
|
|
creation_utc: 133_000_000_000_000n,
|
|
host_key: '.example.com',
|
|
name: 'sid'
|
|
}
|
|
|
|
const params = buildChromiumCookieInsertParams(columns, sourceRow, decryptedValue)
|
|
|
|
expect(params).toEqual([
|
|
133_000_000_000_000n,
|
|
'.example.com',
|
|
'',
|
|
'sid',
|
|
decryptedValue,
|
|
Buffer.alloc(0),
|
|
-1,
|
|
133_000_000_000_000n,
|
|
0
|
|
])
|
|
})
|
|
|
|
it('preserves null for nullable columns without defaults', () => {
|
|
const decryptedValue = Buffer.from('decrypted-cookie-value')
|
|
const columns: ChromiumCookieColumnInfo[] = [
|
|
{ name: 'creation_utc', type: 'INTEGER', notnull: 1 },
|
|
{ name: 'host_key', type: 'TEXT', notnull: 1 },
|
|
{ name: 'nullable_metadata', type: 'TEXT', notnull: 0 },
|
|
{ name: 'target_only_nullable_metadata', type: 'TEXT', notnull: 0 },
|
|
{ name: 'last_update_utc', type: 'INTEGER', notnull: 1 }
|
|
]
|
|
const sourceRow = {
|
|
creation_utc: 133_000_000_000_000n,
|
|
host_key: '.example.com',
|
|
nullable_metadata: null
|
|
}
|
|
|
|
const params = buildChromiumCookieInsertParams(columns, sourceRow, decryptedValue)
|
|
|
|
expect(params).toEqual([133_000_000_000_000n, '.example.com', null, null, 133_000_000_000_000n])
|
|
})
|
|
})
|