mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(browser): fail closed and serialize cookie imports
This commit is contained in:
@@ -72,6 +72,23 @@ describe('cookie clear CDP identities', () => {
|
||||
).toThrow(/Could not snapshot cookie identity/)
|
||||
})
|
||||
|
||||
it('fails closed instead of making an opaque CDP partition restorable as unpartitioned', () => {
|
||||
expect(() =>
|
||||
cookieClearIdentitiesFromCdp(
|
||||
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
|
||||
[
|
||||
{
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
domain: 'app.acme-chips.test',
|
||||
path: '/',
|
||||
partitionKeyOpaque: true
|
||||
}
|
||||
]
|
||||
)
|
||||
).toThrow(/Could not snapshot cookie identity/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ hasCrossSiteAncestor: true },
|
||||
{ topLevelSite: 'not-a-site', hasCrossSiteAncestor: true },
|
||||
|
||||
@@ -26,6 +26,7 @@ type CdpCookie = {
|
||||
expires?: number
|
||||
sameSite?: string
|
||||
partitionKey?: CdpCookiePartitionKey | null
|
||||
partitionKeyOpaque?: boolean
|
||||
}
|
||||
|
||||
type CookieClearDebugger = {
|
||||
@@ -54,18 +55,17 @@ function cdpSameSite(sameSite: Cookie['sameSite']): 'Strict' | 'Lax' | 'None' |
|
||||
}
|
||||
|
||||
function electronSameSite(sameSite: string | undefined): Cookie['sameSite'] {
|
||||
if (sameSite === 'Strict') {
|
||||
return 'strict'
|
||||
}
|
||||
if (sameSite === 'None') {
|
||||
return 'no_restriction'
|
||||
if (sameSite === 'Strict' || sameSite === 'None') {
|
||||
return sameSite === 'Strict' ? 'strict' : 'no_restriction'
|
||||
}
|
||||
return sameSite === 'Lax' ? 'lax' : 'unspecified'
|
||||
}
|
||||
|
||||
function partitionKeyFromCdp(
|
||||
partitionKey: CdpCookiePartitionKey | null | undefined
|
||||
): CookieClearPartitionKey | undefined {
|
||||
function partitionKeyFromCdp(cookie: CdpCookie): CookieClearPartitionKey | undefined {
|
||||
if (cookie.partitionKeyOpaque === true) {
|
||||
throw new Error('Could not snapshot cookie identity for an atomic clear')
|
||||
}
|
||||
const partitionKey = cookie.partitionKey
|
||||
if (partitionKey === undefined) {
|
||||
return undefined
|
||||
}
|
||||
@@ -108,7 +108,7 @@ function indexCdpCookies(cookies: readonly CdpCookie[]): Map<string, CdpCookie[]
|
||||
}
|
||||
|
||||
function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearIdentity {
|
||||
const partitionKey = partitionKeyFromCdp(cdpCookie.partitionKey)
|
||||
const partitionKey = partitionKeyFromCdp(cdpCookie)
|
||||
return {
|
||||
url,
|
||||
name: cdpCookie.name,
|
||||
@@ -199,7 +199,7 @@ export function cookieClearIdentitiesFromCdp(
|
||||
match.name,
|
||||
match.domain,
|
||||
match.path,
|
||||
partitionKeyFromCdp(match.partitionKey) ?? null
|
||||
partitionKeyFromCdp(match) ?? null
|
||||
])
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
|
||||
@@ -51,7 +51,7 @@ export type CookieClearSession = {
|
||||
restoreClearIdentities: CookieClearStore['restoreClearIdentities']
|
||||
}
|
||||
|
||||
const clearLocks = new WeakMap<object, Promise<void>>()
|
||||
const mutationLocks = new WeakMap<object, Promise<void>>()
|
||||
|
||||
function cookieClearKey(url: string, name: string): string {
|
||||
return JSON.stringify([url, name])
|
||||
@@ -74,17 +74,22 @@ export function identitiesFromClearCookies(
|
||||
}))
|
||||
}
|
||||
|
||||
export async function withCookieClearLock<T>(owner: object, run: () => Promise<T>): Promise<T> {
|
||||
const previous = clearLocks.get(owner) ?? Promise.resolve()
|
||||
export async function acquireCookieMutationLock(owner: object): Promise<() => void> {
|
||||
const previous = mutationLocks.get(owner) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
clearLocks.set(
|
||||
mutationLocks.set(
|
||||
owner,
|
||||
previous.then(() => current)
|
||||
)
|
||||
await previous
|
||||
return release
|
||||
}
|
||||
|
||||
export async function withCookieMutationLock<T>(owner: object, run: () => Promise<T>): Promise<T> {
|
||||
const release = await acquireCookieMutationLock(owner)
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
@@ -169,7 +174,7 @@ export async function removeTransplantableCookies(
|
||||
targetSession: CookieClearSession,
|
||||
preserveFamilies: ReadonlySet<string> = new Set()
|
||||
): Promise<void> {
|
||||
return withCookieClearLock(targetSession, async () => {
|
||||
return withCookieMutationLock(targetSession, async () => {
|
||||
const store = targetSession.cookies
|
||||
const initialCookies = await store.get({})
|
||||
if (initialCookies.length === 0) {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
appGetPathMock,
|
||||
clearPendingCookieImportMock,
|
||||
sessionFromPartitionMock,
|
||||
setPendingCookieImportMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: appGetPathMock },
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
vi.mock('node:child_process', () => ({ execFileSync: vi.fn() }))
|
||||
vi.mock('../codex-accounts/fs-utils', async () => {
|
||||
const { copyFileSync } = await import('node:fs')
|
||||
return {
|
||||
copyFileWithWindowsRetry: (sourcePath: string, destinationPath: string) => {
|
||||
if (destinationPath.includes('cookie-import-staging')) {
|
||||
throw new Error('staging intentionally unavailable')
|
||||
}
|
||||
copyFileSync(sourcePath, destinationPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('./browser-session-registry', () => ({
|
||||
browserSessionRegistry: {
|
||||
setPendingCookieImport: setPendingCookieImportMock,
|
||||
clearPendingCookieImport: clearPendingCookieImportMock
|
||||
}
|
||||
}))
|
||||
vi.mock('./browser-cookie-clear-store', () => ({
|
||||
openCookieClearStore: (targetSession: TestSession) => ({
|
||||
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: (identities: Record<string, unknown>[]) =>
|
||||
targetSession.restore(identities),
|
||||
writeCookieIdentity: (identity: Record<string, unknown>) => targetSession.write(identity),
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
|
||||
type JarCookie = {
|
||||
domain: string
|
||||
name: string
|
||||
value: string
|
||||
path: string
|
||||
secure: boolean
|
||||
sameSite: 'unspecified'
|
||||
}
|
||||
|
||||
type TestSession = {
|
||||
cookies: {
|
||||
get: (filter: object) => Promise<JarCookie[]>
|
||||
remove: (url: string, name: string) => Promise<void>
|
||||
set: (details: Record<string, unknown>) => Promise<void>
|
||||
flushStore: () => Promise<void>
|
||||
}
|
||||
clearData: () => Promise<void>
|
||||
getStoragePath: () => string
|
||||
restore: (identities: Record<string, unknown>[]) => Promise<void>
|
||||
write: (identity: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function jarCookie(name: string, value: string): JarCookie {
|
||||
return {
|
||||
domain: '.example.com',
|
||||
name,
|
||||
value,
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'unspecified'
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCoordinate(jar: JarCookie[], identity: Record<string, unknown>): void {
|
||||
const index = jar.findIndex((cookie) => cookie.name === identity.name)
|
||||
const next = jarCookie(identity.name as string, identity.value as string)
|
||||
if (index === -1) {
|
||||
jar.push(next)
|
||||
} else {
|
||||
jar[index] = next
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(
|
||||
storagePath: string,
|
||||
jar: JarCookie[],
|
||||
write: (identity: Record<string, unknown>) => Promise<void>
|
||||
): TestSession {
|
||||
return {
|
||||
cookies: {
|
||||
get: async () => [...jar],
|
||||
remove: async (_url, name) => {
|
||||
for (let index = jar.length - 1; index >= 0; index -= 1) {
|
||||
if (jar[index]?.name === name) {
|
||||
jar.splice(index, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
set: async () => undefined,
|
||||
flushStore: async () => undefined
|
||||
},
|
||||
clearData: async () => {
|
||||
jar.splice(0)
|
||||
},
|
||||
getStoragePath: () => storagePath,
|
||||
restore: async (identities) => {
|
||||
for (const identity of identities) {
|
||||
replaceCoordinate(jar, identity)
|
||||
}
|
||||
},
|
||||
write
|
||||
}
|
||||
}
|
||||
|
||||
function chromeBrowser(cookiesPath: string) {
|
||||
return {
|
||||
family: 'chrome' as const,
|
||||
label: 'Google Chrome',
|
||||
cookiesPath,
|
||||
keychainService: 'Chrome Safe Storage',
|
||||
keychainAccount: 'Chrome',
|
||||
profiles: [{ name: 'Default', directory: 'Default' }],
|
||||
selectedProfile: 'Default'
|
||||
}
|
||||
}
|
||||
|
||||
describe('cookie import mutation transactions', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-cookie-import-concurrency-'))
|
||||
appGetPathMock.mockReset().mockReturnValue(join(root, 'userData'))
|
||||
sessionFromPartitionMock.mockReset()
|
||||
setPendingCookieImportMock.mockReset()
|
||||
clearPendingCookieImportMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('prevents a stale validated-import rollback from deleting a successful concurrent import', async () => {
|
||||
const jar = [jarCookie('original', 'old')]
|
||||
const failureReached = deferred()
|
||||
const releaseFailure = deferred()
|
||||
const secondTargetReady = deferred()
|
||||
const session = makeSession(join(root, 'target'), jar, async (identity) => {
|
||||
if (identity.name === 'reject-after-first') {
|
||||
failureReached.resolve()
|
||||
await releaseFailure.promise
|
||||
throw new Error('rejected')
|
||||
}
|
||||
replaceCoordinate(jar, identity)
|
||||
})
|
||||
let targetCalls = 0
|
||||
sessionFromPartitionMock.mockImplementation(() => {
|
||||
targetCalls += 1
|
||||
if (targetCalls === 2) {
|
||||
secondTargetReady.resolve()
|
||||
}
|
||||
return session
|
||||
})
|
||||
const firstPath = join(root, 'first.json')
|
||||
const secondPath = join(root, 'second.json')
|
||||
writeFileSync(
|
||||
firstPath,
|
||||
JSON.stringify([
|
||||
{ domain: '.example.com', name: 'session', value: 'first', secure: true },
|
||||
{ domain: '.example.com', name: 'reject-after-first', value: 'x', secure: true }
|
||||
])
|
||||
)
|
||||
writeFileSync(
|
||||
secondPath,
|
||||
JSON.stringify([{ domain: '.example.com', name: 'session', value: 'second', secure: true }])
|
||||
)
|
||||
|
||||
const firstImport = importCookiesFromFile(firstPath, 'persist:test')
|
||||
await failureReached.promise
|
||||
const secondImport = importCookiesFromFile(secondPath, 'persist:test')
|
||||
await secondTargetReady.promise
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
releaseFailure.resolve()
|
||||
const [firstResult, secondResult] = await Promise.all([firstImport, secondImport])
|
||||
|
||||
expect(firstResult.ok).toBe(false)
|
||||
expect(secondResult.ok).toBe(true)
|
||||
expect(jar).toEqual([jarCookie('session', 'second')])
|
||||
})
|
||||
|
||||
it('keeps native clear and writes atomic without relying on staged replay', async () => {
|
||||
const targetPath = join(root, 'target')
|
||||
const targetCookiesPath = join(targetPath, 'Network', 'Cookies')
|
||||
mkdirSync(dirname(targetCookiesPath), { recursive: true })
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
||||
const firstSource = join(root, 'first-source', 'Cookies')
|
||||
const secondSource = join(root, 'second-source', 'Cookies')
|
||||
createChromiumCookieTestDatabase(firstSource, [
|
||||
{ domain: '.example.com', name: 'from-first', value: 'first' }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(secondSource, [
|
||||
{ domain: '.example.com', name: 'from-second', value: 'second' }
|
||||
]).close()
|
||||
const jar = [jarCookie('original', 'old')]
|
||||
const firstWriteReached = deferred()
|
||||
const releaseFirstWrite = deferred()
|
||||
const session = makeSession(targetPath, jar, async (identity) => {
|
||||
if (identity.name === 'from-first') {
|
||||
firstWriteReached.resolve()
|
||||
await releaseFirstWrite.promise
|
||||
}
|
||||
replaceCoordinate(jar, identity)
|
||||
})
|
||||
sessionFromPartitionMock.mockReturnValue(session)
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
|
||||
try {
|
||||
const firstImport = importCookiesFromBrowser(chromeBrowser(firstSource), 'persist:test')
|
||||
await firstWriteReached.promise
|
||||
const secondImport = importCookiesFromBrowser(chromeBrowser(secondSource), 'persist:test')
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
releaseFirstWrite.resolve()
|
||||
const [firstResult, secondResult] = await Promise.all([firstImport, secondImport])
|
||||
|
||||
expect(firstResult.ok).toBe(true)
|
||||
expect(secondResult.ok).toBe(true)
|
||||
expect(jar).toEqual([jarCookie('from-second', 'second')])
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -221,6 +221,50 @@ describe('validated import partition fidelity', () => {
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a populated family and creates no staged replay for an opaque JSON partition', async () => {
|
||||
const targetJar = [
|
||||
{
|
||||
name: 'live-session',
|
||||
value: 'must-survive',
|
||||
domain: '.preserved.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
]
|
||||
const remove = vi.fn(async (_url: string, name: string) => {
|
||||
const index = targetJar.findIndex((cookie) => cookie.name === name)
|
||||
if (index !== -1) {
|
||||
targetJar.splice(index, 1)
|
||||
}
|
||||
})
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: { get: vi.fn(async () => targetJar), remove, set: unreachableCookieSet }
|
||||
})
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.preserved.example',
|
||||
name: 'opaque-session',
|
||||
value: 'do-not-downgrade',
|
||||
secure: true,
|
||||
partitionKeyOpaque: true
|
||||
},
|
||||
{ domain: 'sub.preserved.example', name: 'sibling', value: 'do-not-write', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok && result.summary).toMatchObject({
|
||||
importedCookies: 0,
|
||||
skippedCookies: 2,
|
||||
partitionSkippedCookies: 2
|
||||
})
|
||||
expect(targetJar).toEqual([expect.objectContaining({ name: 'live-session' })])
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(cookieWriteMock).not.toHaveBeenCalled()
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['empty string', '']
|
||||
|
||||
@@ -85,8 +85,9 @@ import {
|
||||
type ReplacedImportedDomainCookies
|
||||
} from './browser-cookie-import-policy'
|
||||
import {
|
||||
acquireCookieMutationLock,
|
||||
removeTransplantableCookies,
|
||||
withCookieClearLock,
|
||||
withCookieMutationLock,
|
||||
type CookieClearStore,
|
||||
type CookieImportWriteStore
|
||||
} from './browser-cookie-import-clear'
|
||||
@@ -468,6 +469,7 @@ type RawCookieEntry = {
|
||||
sameSite?: unknown
|
||||
expirationDate?: unknown
|
||||
partitionKey?: unknown
|
||||
partitionKeyOpaque?: unknown
|
||||
}
|
||||
|
||||
// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to
|
||||
@@ -572,7 +574,7 @@ function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null {
|
||||
httpOnly: raw.httpOnly === true || raw.httpOnly === 1,
|
||||
sameSite: normalizeSameSite(raw.sameSite),
|
||||
expirationDate,
|
||||
partition: readJsonCookiePartition(raw.partitionKey)
|
||||
partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,6 +584,7 @@ type CookieImportSessionStore = CookieClearStore & CookieImportWriteStore & { di
|
||||
|
||||
type CookieImportTarget = {
|
||||
partition: string
|
||||
mutationOwner: object
|
||||
openWriteStore: () => CookieImportSessionStore
|
||||
}
|
||||
|
||||
@@ -593,6 +596,7 @@ function cookieImportTarget(targetPartition: string): CookieImportTarget {
|
||||
const targetSession = session.fromPartition(targetPartition)
|
||||
return {
|
||||
partition: targetPartition,
|
||||
mutationOwner: targetSession,
|
||||
openWriteStore: () => openCookieClearStore(targetSession)
|
||||
}
|
||||
}
|
||||
@@ -663,6 +667,9 @@ async function importValidatedCookies(
|
||||
const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null
|
||||
|
||||
if (cookieClearStore) {
|
||||
// Why: replace, writes, and rollback are one live-jar transaction; a stale rollback must not
|
||||
// remove a later import that already reported success.
|
||||
const releaseMutationLock = await acquireCookieMutationLock(target.mutationOwner)
|
||||
let replaced: ReplacedImportedDomainCookies | null = null
|
||||
try {
|
||||
if (mode === 'replace-imported-domains') {
|
||||
@@ -723,7 +730,11 @@ async function importValidatedCookies(
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cookieClearStore.dispose()
|
||||
try {
|
||||
cookieClearStore.dispose()
|
||||
} finally {
|
||||
releaseMutationLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2013,8 +2024,10 @@ export async function importCookiesFromBrowser(
|
||||
// the same CDP identities — cookies.set() cannot express the partition either one reads.
|
||||
const cookieClearStore = openCookieClearStore(targetSession)
|
||||
try {
|
||||
await withCookieClearLock(targetSession, () =>
|
||||
removeTransplantableCookies(
|
||||
// Why: this lock covers only the live jar; staging and cold-start replay keep their existing
|
||||
// semantics while clear and writes can no longer interleave with another import.
|
||||
await withCookieMutationLock(targetSession, async () => {
|
||||
await removeTransplantableCookies(
|
||||
{
|
||||
cookies: cookieClearStore,
|
||||
clearData: (options) => targetSession.clearData(options),
|
||||
@@ -2027,28 +2040,28 @@ export async function importCookiesFromBrowser(
|
||||
// snapshot taken from it, so they are never submitted to any mutation.
|
||||
nativePlan.skippedFamilies
|
||||
)
|
||||
)
|
||||
diag(
|
||||
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
|
||||
)
|
||||
diag(
|
||||
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
|
||||
)
|
||||
|
||||
const writable: SourceCookieToWrite[] = []
|
||||
for (const cookie of decryptedCookies) {
|
||||
const url = deriveUrl(cookie.domain, cookie.secure)
|
||||
if (!url) {
|
||||
memoryFailed++
|
||||
continue
|
||||
const writable: SourceCookieToWrite[] = []
|
||||
for (const cookie of decryptedCookies) {
|
||||
const url = deriveUrl(cookie.domain, cookie.secure)
|
||||
if (!url) {
|
||||
memoryFailed++
|
||||
continue
|
||||
}
|
||||
writable.push({ ...cookie, url })
|
||||
}
|
||||
writable.push({ ...cookie, url })
|
||||
}
|
||||
// Why: a rejected cookie here falls back to the staged cold-start replay rather than
|
||||
// unwinding the import, so one failure must not stop the rest from loading.
|
||||
const phase = await writeImportedCookies(cookieClearStore, writable, {
|
||||
stopOnFailure: false,
|
||||
log: diag
|
||||
// Why: a rejected cookie here falls back to the staged cold-start replay rather than
|
||||
// unwinding the import, so one failure must not stop the rest from loading.
|
||||
const phase = await writeImportedCookies(cookieClearStore, writable, {
|
||||
stopOnFailure: false,
|
||||
log: diag
|
||||
})
|
||||
memoryLoaded = phase.importedCount
|
||||
memoryFailed += phase.writeRejected
|
||||
})
|
||||
memoryLoaded = phase.importedCount
|
||||
memoryFailed += phase.writeRejected
|
||||
} finally {
|
||||
cookieClearStore.dispose()
|
||||
}
|
||||
|
||||
@@ -113,6 +113,24 @@ describe('readJsonCookiePartition', () => {
|
||||
expect(readJsonCookiePartition(undefined)).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
it('refuses an opaque partition key even when its key object is absent or populated', () => {
|
||||
expect(readJsonCookiePartition(undefined, true)).toEqual({
|
||||
status: 'unreadable',
|
||||
reason: 'partition key was opaque'
|
||||
})
|
||||
expect(
|
||||
readJsonCookiePartition(
|
||||
{ topLevelSite: 'https://top.example', hasCrossSiteAncestor: true },
|
||||
true
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('accepts the explicit non-opaque CDP shape and rejects a malformed opaque flag', () => {
|
||||
expect(readJsonCookiePartition(undefined, false)).toEqual({ status: 'unpartitioned' })
|
||||
expect(readJsonCookiePartition(undefined, 'false').status).toBe('unreadable')
|
||||
})
|
||||
|
||||
it.each([null, ''])('refuses a present but empty partitionKey (%s)', (partitionKey) => {
|
||||
expect(readJsonCookiePartition(partitionKey).status).toBe('unreadable')
|
||||
})
|
||||
|
||||
@@ -127,7 +127,16 @@ export function readFirefoxRowPartition(
|
||||
* exporters that emit only `topLevelSite` (or the legacy CDP string form) carry no ancestor bit, and
|
||||
* guessing it silently misfiles the cookie.
|
||||
*/
|
||||
export function readJsonCookiePartition(raw: unknown): SourcePartitionRead {
|
||||
export function readJsonCookiePartition(
|
||||
raw: unknown,
|
||||
partitionKeyOpaque: unknown = undefined
|
||||
): SourcePartitionRead {
|
||||
if (partitionKeyOpaque === true) {
|
||||
return { status: 'unreadable', reason: 'partition key was opaque' }
|
||||
}
|
||||
if (partitionKeyOpaque !== undefined && typeof partitionKeyOpaque !== 'boolean') {
|
||||
return { status: 'unreadable', reason: 'partitionKeyOpaque was not a boolean' }
|
||||
}
|
||||
if (raw === undefined) {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user