Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions sdk/highlight-run/src/__tests__/highlightSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Cookies from 'js-cookie'

import {
setSessionData,
type SessionData,
} from '../client/utils/sessionStorage/highlightSession'
import { SESSION_PUSH_THRESHOLD } from '../client/constants/sessions'
import { cookieStorage, setItem } from '../client/utils/storage'

const sessionDataKey = (id: string) => `sessionData_${id}`

const makeSessionData = (
id: string,
overrides: Partial<SessionData> = {},
): SessionData => ({
sessionSecureID: id,
projectID: 1,
sessionStartTime: Date.now(),
lastPushTime: Date.now(),
...overrides,
})

const clearAllSessionData = () => {
if (typeof window !== 'undefined') {
try {
window.localStorage.clear()
} catch {}
}
for (const key of Object.keys(Cookies.get() ?? {})) {
Cookies.remove(key)
}
}

describe('pruneSessionData', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2024, 0, 1, 12, 0, 0))
clearAllSessionData()
})

afterEach(() => {
clearAllSessionData()
vi.useRealTimers()
})

it('removes stale sessionData_* cookies that have no matching localStorage entry', () => {
// Simulate cookies left behind from prior sessions, with no matching
// localStorage entries (e.g. user cleared site data, different subdomain,
// or localStorage was unavailable when they were written).
const stale = makeSessionData('old-1', {
lastPushTime: Date.now() - SESSION_PUSH_THRESHOLD - 1000,
})
cookieStorage.setItem(sessionDataKey('old-1'), JSON.stringify(stale))

const fresh = makeSessionData('new-session')
setSessionData(fresh)

expect(cookieStorage.getItem(sessionDataKey('old-1'))).toBe('')
expect(cookieStorage.getItem(sessionDataKey('new-session'))).not.toBe(
'',
)
})

it('removes sessionData_* cookies with corrupt/truncated values without logging', () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
cookieStorage.setItem(sessionDataKey('truncated'), '{"sessionSecu')

setSessionData(makeSessionData('new-session'))

expect(cookieStorage.getItem(sessionDataKey('truncated'))).toBe('')
expect(consoleError).not.toHaveBeenCalled()
consoleError.mockRestore()
})

it('removes entries with no timestamp metadata', () => {
cookieStorage.setItem(
sessionDataKey('no-ts'),
JSON.stringify({ sessionSecureID: 'no-ts', projectID: 1 }),
)

setSessionData(makeSessionData('new-session'))

expect(cookieStorage.getItem(sessionDataKey('no-ts'))).toBe('')
})

it('keeps recent session data from a parallel tab', () => {
// Parallel tabs write to both cookies and localStorage via setItem.
const recent = makeSessionData('parallel', {
lastPushTime: Date.now() - 1000,
})
setItem(sessionDataKey('parallel'), JSON.stringify(recent))

setSessionData(makeSessionData('new-session'))

expect(cookieStorage.getItem(sessionDataKey('parallel'))).not.toBe('')
expect(cookieStorage.getItem(sessionDataKey('new-session'))).not.toBe(
'',
)
})

it('falls back to sessionStartTime when lastPushTime is missing', () => {
const stale = {
sessionSecureID: 'started-never-pushed',
projectID: 1,
sessionStartTime: Date.now() - SESSION_PUSH_THRESHOLD - 1000,
}
cookieStorage.setItem(
sessionDataKey('started-never-pushed'),
JSON.stringify(stale),
)

setSessionData(makeSessionData('new-session'))

expect(
cookieStorage.getItem(sessionDataKey('started-never-pushed')),
).toBe('')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -103,37 +103,49 @@ export const loadCookieSessionData = function () {

function pruneSessionData(keepKey: string): void {
const prefix = `${SESSION_STORAGE_KEYS.SESSION_DATA}_`
const candidates = new Set<string>()

// Walk backwards so index order isn’t upset by removals.
for (let i = getPersistentStorage().length - 1; i >= 0; i--) {
const key = getPersistentStorage().key(i)
const storage = getPersistentStorage()
for (let i = storage.length - 1; i >= 0; i--) {
const key = storage.key(i)
if (key && key.startsWith(prefix) && key !== keepKey) {
try {
const sessionData = JSON.parse(
getItem(key) || '{}',
) as SessionData
if (
sessionData.lastPushTime !== undefined &&
Date.now() - sessionData.lastPushTime >=
SESSION_PUSH_THRESHOLD
) {
internalLogOnce(
'highlightSession',
'pruneSessionData',
'debug',
`removing session data for stale key ${key}`,
)
removeItem(key)
}
} catch (e) {
internalLogOnce(
'highlightSession',
'pruneSessionData',
'error',
`failed to parse session data for key ${key}`,
e,
)
}
candidates.add(key)
}
}

// Cookies are written alongside localStorage but persist independently
// (they survive localStorage being cleared, and outlive many sessions
// via the SESSION_PUSH_THRESHOLD expiry). Without enumerating them here,
// sessionData_* cookies pile up over time and bloat the Cookie header
// enough to break unrelated API calls.
for (const key of cookieStorage.keys()) {
if (key.startsWith(prefix) && key !== keepKey) {
candidates.add(key)
}
}

for (const key of candidates) {
let stale = true
try {
const sessionData = JSON.parse(getItem(key) || '{}') as SessionData
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cookie-only entries always treated as stale due to localStorage-only read

Medium Severity

The freshness check uses getItem(key) which only reads from getPersistentStorage() (localStorage), never from cookies. For entries discovered via cookieStorage.keys() that have no matching localStorage entry — the exact scenario this PR targets — getItem returns '', producing JSON.parse('{}') with no timestamps, so ts is always undefined and stale is always true. This means all cookie-only entries are unconditionally removed regardless of whether they contain fresh session data for an active parallel tab. The freshness check needs to fall back to cookieStorage.getItem(key) when getItem(key) returns empty.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 04514c3. Configure here.

// Fall back to sessionStartTime so entries from sessions that
// ended before the first push still age out. Entries with no
// usable timestamp (foreign / corrupt / truncated) are stale.
const ts = sessionData.lastPushTime ?? sessionData.sessionStartTime
stale =
ts === undefined || Date.now() - ts >= SESSION_PUSH_THRESHOLD
} catch {
// Truncated past the 4KB cookie limit or otherwise corrupt —
// drop silently rather than logging on every page load.
}
if (stale) {
internalLogOnce(
'highlightSession',
'pruneSessionData',
'debug',
`removing session data for stale key ${key}`,
)
removeItem(key)
}
}
}
4 changes: 4 additions & 0 deletions sdk/highlight-run/src/client/utils/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export class CookieStorage {
}
Cookies.remove(key)
}

public keys(): string[] {
return Object.keys(Cookies.get() ?? {})
}
}

export const globalStorage = new Storage()
Expand Down
Loading