Compare commits

...
Author SHA1 Message Date
james.maes 8abc8e0066 test(qqq#952): capture session POST and dashboard reloads 2026-10-02 02:37:11 -05:00
4 changed files with 115 additions and 20 deletions

No files matched your search

+8 -4
View File
@@ -61,10 +61,14 @@ QQQ_ACCEPTANCE_MODE=standalone pnpm test:acceptance # container-image build
ordinary server logs can still contain session data; keep raw evidence private.
- **Document/XHR evidence (opt-in).** Set `QQQ_ACCEPTANCE_BROWSER_TRACE=1` locally to
attach `browser-boundary.json` through the diagnostics fixture; CI does not enable it.
Only `/app/person[/]`, `/app/person/2[/]`, and `/app/person.bulkEdit[/]` documents on
the configured UI/API origins are observed. Requests are exact method/path matches:
GET person metadata, session, person record 2 and person.bulkEdit process metadata;
POST querySavedView init, person query/count and person.bulkEdit init. The helper emits
By default, only `/app/person[/]`, `/app/person/2[/]`, and `/app/person.bulkEdit[/]`
documents on the configured UI/API origins are observed. To also capture dashboard
documents at exactly `/app` and `/app/`, set `QQQ_ACCEPTANCE_BROWSER_TRACE_SESSION=1`
alongside `QQQ_ACCEPTANCE_BROWSER_TRACE=1` locally. The session flag alone installs
nothing; other dashboard paths are not included. Requests are exact method/path matches:
GET person metadata, person record 2 and person.bulkEdit process metadata;
POST `/qqq/v1/manageSession`, querySavedView init, person query/count and person.bulkEdit init.
GET `/qqq/v1/manageSession` is excluded. The helper emits
fixed aliases, document UUID/sequence/clocks, native XHR send return/throw, status and
terminal events. It never emits URLs, query/hash values, headers, credentials, payloads,
error messages or stacks, and adds no request headers or interception. Disabled mode
@@ -69,6 +69,35 @@ function event(overrides: EventRecord = {}) {
}
describe('document-local boundary capture', () => {
it('captures the production session POST and excludes GET and other session paths', () => {
install()
const xhr = new NativeXHR()
const body = { accessToken: 'BODY_SECRET' }
expect(xhr.open('POST', '/qqq/v1/manageSession?token=QUERY_SECRET#HASH_SECRET')).toBe(xhr.openResult)
expect(xhr.send(body)).toBe(xhr.sendResult)
xhr.state(2, 200)
xhr.state(4, 200)
xhr.dispatchEvent(new Event('load'))
xhr.dispatchEvent(new Event('loadend'))
expect(kinds()).toEqual(['ready', 'open-return', 'send-enter', 'send-return', 'headers', 'done', 'load', 'loadend'])
expect(events.slice(1).every(row => row.endpoint === 'session' && row.method === 'POST')).toBe(true)
expect(xhr.calls[1]).toEqual({ operation: 'send', receiver: xhr, args: [body] })
const before = events.length
for (const [method, url] of [
['GET', '/qqq/v1/manageSession'], ['POST', '/qqq/v1/manageSession/'],
['POST', '/qqq/v1/manageSessionExtra'], ['POST', 'https://untrusted.invalid/qqq/v1/manageSession'],
]) {
const excluded = new NativeXHR()
excluded.open(method, url)
excluded.send(body)
excluded.state(4, 200)
excluded.dispatchEvent(new Event('loadend'))
expect(excluded.calls).toHaveLength(2)
}
expect(events).toHaveLength(before)
expect(JSON.stringify(events)).not.toMatch(/SECRET|accessToken|manageSession|untrusted/)
})
it('observes native arguments/results without reading credentials or payloads', () => {
install()
const xhr = new NativeXHR()
@@ -88,7 +117,7 @@ describe('document-local boundary capture', () => {
it('rethrows the identical native exception and never reads its message or name', () => {
install()
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
const failure = new Error('MESSAGE_SECRET')
Object.defineProperty(failure, 'name', { get() { throw new Error('must not read name') } })
xhr.sendFailure = failure
@@ -104,7 +133,7 @@ describe('document-local boundary capture', () => {
it('preserves failed open and unknown URL objects without coercion or stale request identity', () => {
install()
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
const url = { toString: vi.fn(() => 'OBJECT_SECRET') }
xhr.open('GET', url)
xhr.send('BODY_SECRET')
@@ -114,7 +143,7 @@ describe('document-local boundary capture', () => {
xhr.state(4, 200)
xhr.dispatchEvent(new Event('loadend'))
xhr.openFailure = failure
expect(() => xhr.open('GET', '/qqq/v1/manageSession')).toThrow(failure)
expect(() => xhr.open('POST', '/qqq/v1/manageSession')).toThrow(failure)
expect(events.at(-1)).toMatchObject({ event: 'open-throw', errorClass: 'DOMException' })
expect(JSON.stringify(events)).not.toMatch(/SECRET|PRIVATE/)
})
@@ -122,7 +151,7 @@ describe('document-local boundary capture', () => {
it('uses one listener set and distinguishes reuse after the prior loadend', () => {
install()
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
xhr.state(2, 200)
xhr.state(2, 200)
@@ -159,7 +188,7 @@ describe('document-local boundary capture', () => {
sendResult = xhr.send(body)
})
const start = events.length
expect(xhr.open('GET', '/qqq/v1/manageSession')).toBe(xhr.openResult)
expect(xhr.open('POST', '/qqq/v1/manageSession')).toBe(xhr.openResult)
expect(sendResult).toBe(xhr.sendResult)
expect(xhr.calls.at(-1)).toEqual({ operation: 'send', receiver: xhr, args: [body] })
const sendCount = scenario === 'reopen-send' ? 2 : 1
@@ -176,7 +205,7 @@ describe('document-local boundary capture', () => {
expect(after[0]).not.toHaveProperty('generation')
expect(after[0]).not.toHaveProperty('attempt')
// Once ambiguous, reusing that instance cannot silently regain trustworthy attribution.
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
expect(events.slice(start)).toEqual(after)
const next = new NativeXHR()
@@ -195,7 +224,7 @@ describe('document-local boundary capture', () => {
xhr.open('POST', '/qqq/v1/table/person/count')
xhr.send()
})
xhr.open('GET', initialURL)
xhr.open('POST', initialURL)
xhr.send()
xhr.state(4, 200)
const start = events.length
@@ -212,7 +241,7 @@ describe('document-local boundary capture', () => {
it('keeps terminal events on the active send when a repeated send throws', () => {
install()
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
xhr.sendFailure = new DOMException('PRIVATE', 'InvalidStateError')
expect(() => xhr.send()).toThrow(DOMException)
@@ -237,7 +266,7 @@ describe('document-local boundary capture', () => {
install()
const xhr = new NativeXHR()
vi.stubGlobal('__qqqBrowserBoundary', () => { throw new Error('OBSERVER_SECRET') })
expect(xhr.open('GET', '/qqq/v1/manageSession')).toBe(xhr.openResult)
expect(xhr.open('POST', '/qqq/v1/manageSession')).toBe(xhr.openResult)
vi.stubGlobal('__qqqBrowserBoundary', () => Promise.reject(new Error('OBSERVER_SECRET')))
expect(xhr.send()).toBe(xhr.sendResult)
await Promise.resolve()
@@ -255,7 +284,7 @@ describe('document-local boundary capture', () => {
install()
for (const url of ['/qqq/v1/table/person/PRIVATE', 'https://unrelated.invalid/qqq/v1/manageSession']) {
const xhr = new NativeXHR()
xhr.open('GET', url)
xhr.open('POST', url)
xhr.send('SECRET')
xhr.state(4, 200)
xhr.dispatchEvent(new Event('loadend'))
@@ -304,6 +333,19 @@ describe('document-local boundary capture', () => {
expect(events).toHaveLength(before)
})
it.each(['/application', '/app/other', '/app/person/1', '/app//', '/app/%2f'])('does not widen dashboard capture to %s', path => {
history.replaceState({}, '', path)
cleanup = browserBoundaryInit({ ...config, includeDashboardPaths: true })
const xhr = new NativeXHR()
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
window.dispatchEvent(new PageTransitionEvent('pageshow'))
expect(events).toEqual([])
expect(cleanup).toBeUndefined()
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')).toEqual(originalOpen)
expect(xhr.calls).toHaveLength(2)
})
it('preserves an invalid native receiver and restores native descriptors on cleanup', () => {
install()
expect(() => NativeXHR.prototype.send.call(null as unknown as NativeXHR)).toThrow(TypeError)
@@ -315,7 +357,7 @@ describe('document-local boundary capture', () => {
it('bounds document output with an explicit overflow event', () => {
cleanup = browserBoundaryInit({ ...config, maxEvents: 3 })
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
xhr.state(4, 200)
xhr.dispatchEvent(new Event('loadend'))
@@ -325,6 +367,40 @@ describe('document-local boundary capture', () => {
})
describe('Node capture boundary', () => {
it.each(['/app', '/app/'])('installs dashboard session capture only when opted in at %s', async path => {
history.replaceState({}, '', path)
install()
expect(events).toEqual([])
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')).toEqual(originalOpen)
const context = { exposeBinding: vi.fn(), addInitScript: vi.fn() }
const capture = await installBrowserBoundaryCapture(context, {
enabled: true, origins: config.origins, includeDashboardPaths: true,
})
const binding = context.exposeBinding.mock.calls[0][1]
const source = { page: {}, frame: {} }
vi.stubGlobal('__qqqBrowserBoundary', (value: EventRecord) => binding(source, value))
const [init, options] = context.addInitScript.mock.calls[0]
cleanup = init(options)
const xhr = new NativeXHR()
xhr.open('POST', '/qqq/v1/manageSession?private=QUERY_SECRET')
xhr.send({ accessToken: 'BODY_SECRET' })
xhr.state(2, 200)
xhr.state(4, 200)
xhr.dispatchEvent(new Event('load'))
xhr.dispatchEvent(new Event('loadend'))
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: false }))
const snapshot = capture!.snapshot()
expect(snapshot).toMatchObject({ invalid: 0, dropped: 0 })
expect(snapshot.events.map(row => row.event)).toEqual([
'ready', 'open-return', 'send-enter', 'send-return', 'headers', 'done', 'load', 'loadend', 'pageshow',
])
expect(snapshot.events.every(row => row.page === 'dashboard' && row.pageId === 'page-1' && row.frameId === 'frame-1')).toBe(true)
expect(snapshot.events[2]).toMatchObject({ endpoint: 'session', method: 'POST' })
snapshot.events[0].page = 'CHANGED'
expect(capture!.snapshot().events[0].page).toBe('dashboard')
expect(JSON.stringify(capture!.snapshot())).not.toMatch(/SECRET|accessToken|private=|manageSession/)
})
it('rejects malformed/private payloads and bounds records with explicit counters', () => {
const buffer = createBoundaryBuffer(2)
buffer.add(event(), 'page-1', 'frame-1')
@@ -339,9 +415,21 @@ describe('Node capture boundary', () => {
expect(JSON.stringify(buffer.snapshot())).not.toContain('SECRET')
})
it('retains copied bounded dashboard metadata and rejects raw paths and private fields', () => {
const buffer = createBoundaryBuffer(1)
const row = event({ page: 'dashboard', endpoint: 'session', method: 'POST' })
buffer.add(row, 'page-1', 'frame-1')
row.page = 'MUTATED_SECRET'
buffer.add(event({ page: '/app?token=PRIVATE' }), 'page-1', 'frame-1')
buffer.add(event({ page: 'dashboard', cookies: 'PRIVATE' }), 'page-1', 'frame-1')
buffer.add(event({ page: 'dashboard', seq: 2 }), 'page-1', 'frame-1')
expect(buffer.snapshot()).toMatchObject({ invalid: 2, dropped: 1, events: [{ page: 'dashboard', endpoint: 'session', method: 'POST' }] })
expect(JSON.stringify(buffer.snapshot())).not.toMatch(/SECRET|PRIVATE/)
})
it('disabled installer creates no bindings, init scripts or capture handle', async () => {
const context = { exposeBinding: vi.fn(), addInitScript: vi.fn() }
expect(await installBrowserBoundaryCapture(context, { enabled: false, origins: config.origins })).toBeUndefined()
expect(await installBrowserBoundaryCapture(context, { enabled: false, origins: config.origins, includeDashboardPaths: true })).toBeUndefined()
expect(context.exposeBinding).not.toHaveBeenCalled()
expect(context.addInitScript).not.toHaveBeenCalled()
})
@@ -11,6 +11,7 @@ interface BrowserCaptureConfig {
origins: string[]
maxEvents: number
includeProcessPaths?: boolean
includeDashboardPaths?: boolean
}
type BoundaryEvent = Record<string, string | number | boolean>
@@ -18,12 +19,13 @@ type BoundaryEvent = Record<string, string | number | boolean>
/** Runs before application scripts. Keep all runtime dependencies inside this serializable function. */
export function browserBoundaryInit(config: BrowserCaptureConfig): (() => void) | undefined {
const pages: Record<string, string> = { '/app/person': 'person', '/app/person/': 'person' }
if (config.includeDashboardPaths) Object.assign(pages, { '/app': 'dashboard', '/app/': 'dashboard' })
const endpoints: Record<string, string> = {
'GET /qqq/v1/metaData/table/person': 'person-metadata',
'POST /qqq/v1/processes/querySavedView/init': 'saved-view-init',
'POST /qqq/v1/table/person/query': 'person-query',
'POST /qqq/v1/table/person/count': 'person-count',
'GET /qqq/v1/manageSession': 'session',
'POST /qqq/v1/manageSession': 'session',
}
if (config.includeProcessPaths) {
Object.assign(pages, { '/app/person/2': 'person-record', '/app/person/2/': 'person-record',
@@ -228,7 +230,7 @@ export function createBoundaryBuffer(maxEvents = 4096) {
const limit = Math.max(1, Math.min(maxEvents, 4096))
const numeric = ['seq', 'timeOrigin', 'now', 'xhr', 'generation', 'attempt', 'status', 'readyState']
const enums: Record<string, string[]> = {
page: ['person', 'person-record', 'person-bulk-edit'], phase: ['active', 'pagehide'],
page: ['person', 'person-record', 'person-bulk-edit', 'dashboard'], phase: ['active', 'pagehide'],
event: ['ready', 'pagehide', 'pageshow', 'DOMContentLoaded', 'load', 'visibilitychange', 'open-return', 'open-throw', 'send-enter', 'send-return', 'send-throw', 'headers', 'done', 'error', 'abort', 'timeout', 'loadend', 'overflow', 'xhr-limit', 'xhr-incomplete'],
reason: ['reentrant-open', 'send-during-open', 'reuse-before-loadend'],
endpoint: ['person-metadata', 'saved-view-init', 'person-query', 'person-count', 'session', 'person-record', 'process-metadata', 'process-init'],
@@ -266,7 +268,7 @@ export function createBoundaryBuffer(maxEvents = 4096) {
/** Installs nothing when disabled; snapshotting reads Node memory only, even after page destruction. */
export async function installBrowserBoundaryCapture(
context: Pick<BrowserContext, 'exposeBinding' | 'addInitScript'>,
config: { enabled: boolean; origins: string[]; includeProcessPaths?: boolean },
config: { enabled: boolean; origins: string[]; includeProcessPaths?: boolean; includeDashboardPaths?: boolean },
) {
if (!config.enabled) return undefined
const buffer = createBoundaryBuffer()
@@ -279,6 +281,6 @@ export async function installBrowserBoundaryCapture(
if (!frames.has(frame)) frames.set(frame, `frame-${++frameSequence}`)
buffer.add(value, pages.get(page)!, frames.get(frame)!)
})
await context.addInitScript(browserBoundaryInit, { origins: config.origins, maxEvents: 512, includeProcessPaths: config.includeProcessPaths })
await context.addInitScript(browserBoundaryInit, { origins: config.origins, maxEvents: 512, includeProcessPaths: config.includeProcessPaths, includeDashboardPaths: config.includeDashboardPaths })
return { snapshot: buffer.snapshot }
}
+1
View File
@@ -100,6 +100,7 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
enabled: process.env.QQQ_ACCEPTANCE_BROWSER_TRACE === '1',
origins: [new URL(ACCEPTANCE_UI_URL).origin, new URL(ACCEPTANCE_BACKEND_URL).origin],
includeProcessPaths: true,
includeDashboardPaths: process.env.QQQ_ACCEPTANCE_BROWSER_TRACE_SESSION === '1',
})
try {
await provide()