From 567533478fbb90a6c986ce114d45592da3c36a4b Mon Sep 17 00:00:00 2001 From: a1chzt Date: Tue, 22 Sep 2026 20:36:09 +0800 Subject: [PATCH] fix(e2e): retain complete share probe HTTP evidence --- docs/testing/share-chain-probe.md | 10 ++++- .../playwright/share-chain/http-evidence.ts | 39 +++++++++++++++++ e2e/lib/playwright/share-chain/probe.ts | 29 ++++++------- e2e/tests/share-chain/http-evidence.test.ts | 42 +++++++++++++++++++ 4 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 e2e/lib/playwright/share-chain/http-evidence.ts create mode 100644 e2e/tests/share-chain/http-evidence.test.ts diff --git a/docs/testing/share-chain-probe.md b/docs/testing/share-chain-probe.md index ca76aaee82..e1b1108af5 100644 --- a/docs/testing/share-chain-probe.md +++ b/docs/testing/share-chain-probe.md @@ -23,7 +23,15 @@ with-env corepack pnpm --dir e2e exec tsx scripts/playwright.ts share-chain-prob --execute "$REAL_URL" "$NEW_EVIDENCE_DIRECTORY" ``` -Use a **new** output directory; existing directories are rejected. Headers, cookies, storageState, credentials and auth responses are never recorded. Relevant HTTP JSON is redacted; screenshots/rendered work/comment text are still sensitive and belong only to authorized test accounts in the private evidence directory. No tracing of the SSO/login page. Startup/stop of the preexisting Owner runtime remains with its owner via tools-dev; retain those lifecycle logs alongside the probe output. The probe closes only contexts it creates and disconnects its CDP client. It does not stop another session's runtime. Exit 0=all criteria PASS,1=FAIL,2=UNKNOWN present. Per-step `report.json`, filtered/redacted `http-evidence.json`, PG text output, screenshots and lifecycle records are retained. +Use a **new** output directory; existing directories are rejected. Headers, cookies, storageState, credentials and auth response bodies are never recorded. Relevant HTTP JSON is redacted; screenshots/rendered work/comment text are still sensitive and belong only to authorized test accounts in the private evidence directory. No tracing of the SSO/login page. Startup/stop of the preexisting Owner runtime remains with its owner via tools-dev; retain those lifecycle logs alongside the probe output. The probe closes only contexts it creates and disconnects its CDP client. It does not stop another session's runtime. Exit 0=all criteria PASS,1=FAIL,2=UNKNOWN present. Per-step `report.json`, redacted `http-evidence.json` (all response statuses, business-body allowlist only) and `http-requests.json` (request attempts, including those with no response), PG text output, screenshots and lifecycle records are retained. + +## HTTP evidence completeness + +Request attempts are retained independently of responses, so a blocked/failed directory request cannot disappear from the evidence. Capturing an attempt does not classify it as allowed or forbidden. All responses retain status/method/type/redacted URL, but only canonical comment, publication and metadata endpoints have JSON bodies read. Auth, directory and artifact-resource bodies are not read. URL userinfo/fragments and arbitrary query values are redacted; non-HTTP URL payloads are omitted. Persistence applies the existing identity/secret redactor as well. + +`bodyRead` distinguishes `complete` (including a genuine JSON null), `unavailable` (for example a lost CDP response body), and `not-requested` (outside the body allowlist). An unavailable body is missing proof, never evidence of an empty response or a successful contract assertion. Body reads start in the response callback; the observer does not retry requests or promise that CDP will retain every body. Listeners are removed before pending reads are drained on exit. + +The local recorder unit tests are not browser/privacy/SSO acceptance. Actual anonymous visibility, nonempty author-snapshot provenance, and absence of directory exposure still require the dispatched real observations. ## Data lineage / chronology diff --git a/e2e/lib/playwright/share-chain/http-evidence.ts b/e2e/lib/playwright/share-chain/http-evidence.ts new file mode 100644 index 0000000000..b2dcc62b7e --- /dev/null +++ b/e2e/lib/playwright/share-chain/http-evidence.ts @@ -0,0 +1,39 @@ +export interface HttpRequestSource { + url(): string; + method(): string; + resourceType(): string; +} +export interface HttpResponseSource { + url(): string; + status(): number; + request(): HttpRequestSource; + json(): Promise; +} +function parseEvidenceUrl(value: string): URL | null { + try { return new URL(value); } catch { return null; } +} +/** Never persist URL userinfo, fragments, or arbitrary query parameters. */ +export function evidenceUrl(value: string): string { + const url = parseEvidenceUrl(value); + if (!url) return 'invalid:'; + if (!['http:', 'https:'].includes(url.protocol)) return `${url.protocol}`; + url.username = ''; url.password = ''; url.hash = ''; + for (const key of [...url.searchParams.keys()]) { + if (!['projectId', 'filePath'].includes(key)) url.searchParams.set(key, ''); + } + return url.href; +} +export function captureHttpRequest(request: HttpRequestSource, page: string) { + return { page, at: Date.now(), method: request.method(), type: request.resourceType(), url: evidenceUrl(request.url()) }; +} +/** Only business bodies needed by the probe are read. Identity/directory/auth bodies are not evidence inputs. */ +export function mayReadEvidenceBody(pathname: string): boolean { + return /^\/(?:api\/)?v1\/(?:public\/(?:shares|snapshots)\/[^/]+|collab\/share\/[^/]+\/comments)$/.test(pathname) + || /^\/api\/projects\/[^/]+\/(?:comments(?:\/(?:sync-state|read-state))?|conversations\/[^/]+\/comments|files\/.+\/publish-public)$/.test(pathname); +} +export async function captureHttpResponse(response: HttpResponseSource, page: string) { + const record = { ...captureHttpRequest(response.request(), page), status: response.status() }; + if (!mayReadEvidenceBody(parseEvidenceUrl(response.url())?.pathname ?? '')) return { ...record, body: null, bodyRead: 'not-requested' as const }; + try { return { ...record, body: await response.json(), bodyRead: 'complete' as const }; } + catch { return { ...record, body: null, bodyRead: 'unavailable' as const }; } +} diff --git a/e2e/lib/playwright/share-chain/probe.ts b/e2e/lib/playwright/share-chain/probe.ts index 84f9997b09..6eab33eb36 100644 --- a/e2e/lib/playwright/share-chain/probe.ts +++ b/e2e/lib/playwright/share-chain/probe.ts @@ -1,14 +1,15 @@ -import { chromium, type Page, type Response, type BrowserContext, type Browser } from '@playwright/test'; +import { chromium, type Page, type Request, type Response, type BrowserContext, type Browser } from '@playwright/test'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { captureHttpRequest, captureHttpResponse } from './http-evidence.js'; export type Verdict = 'PASS' | 'FAIL' | 'UNKNOWN'; type Check = { name: string; verdict: Verdict; evidence: unknown }; type Step = { step: number; title: string; verdict: Verdict; checks: Check[]; inputs: unknown; outputs: unknown }; type Json = Record; -type Seen = { page: string; at: number; method: string; url: string; status: number; body: unknown }; +type Seen = Awaited>; export interface ProbeOptions { /** Expected real published URL, not an alternative source for any downstream step. */ url: string; @@ -33,12 +34,6 @@ function safe(value: unknown): unknown { /password|token|cookie|authorization|secret|email|displayName|authorKey|appUserId/i.test(key) ? '' : safe(item)])); return value; } -function reportUrl(value: string): string { - const url = new URL(value); - url.username = ''; url.password = ''; - for (const key of [...url.searchParams.keys()]) if (!['projectId', 'filePath'].includes(key)) url.searchParams.set(key, ''); - return url.href; -} class Missing extends Error {} function requireFact(value: T | undefined, message: string): T { if (value === undefined) throw new Missing(message); @@ -64,21 +59,20 @@ export async function runShareChainProbe(options: ProbeOptions): Promise const save = async (name: string, value: unknown) => writeFile(join(options.outputDir, name), JSON.stringify(safe(value), null, 2), { mode: 0o600 }); const persist = () => save('report.json', { executedAt: new Date().toISOString(), steps, ci: 'UNKNOWN', deployment: 'UNKNOWN', production: 'UNKNOWN' }); const seen: Seen[] = []; + const requests: ReturnType[] = []; const pending = new Set>(); const listeners: Array<() => void> = []; const contexts: BrowserContext[] = []; const observe = (page: Page, label: string) => { + const requestListener = (request: Request) => requests.push(captureHttpRequest(request, label)); const listener = (response: Response) => { - const url = new URL(response.url()); - const relevant = /\/comments(?:\/|$)|\/publish-public$|\/public\/(?:shares|snapshots)\//.test(url.pathname); - if (!relevant) return; - const task = (async () => { - const body = await response.json().catch(() => null); - seen.push({ page: label, at: Date.now(), method: response.request().method(), url: reportUrl(response.url()), status: response.status(), body }); - })(); + // Begin the body read in the response callback, not after later navigation. + const task = captureHttpResponse(response, label).then(record => { seen.push(record); }); pending.add(task); void task.finally(() => pending.delete(task)); }; - page.on('response', listener); listeners.push(() => page.off('response', listener)); + page.on('request', requestListener); + page.on('response', listener); + listeners.push(() => { page.off('request', requestListener); page.off('response', listener); }); }; const settle = () => Promise.all([...pending]); let current = 0; @@ -287,8 +281,9 @@ export async function runShareChainProbe(options: ProbeOptions): Promise await finish(null); for (const step of steps.slice(current + 1)) step.inputs = { blockedByStep: current + 1, reason: 'No substituted upstream outputs' }; } finally { - await settle(); listeners.forEach(remove => remove()); + listeners.forEach(remove => remove()); await settle(); await save('http-evidence.json', seen); + await save('http-requests.json', requests); for (const context of contexts.reverse()) await context.close().catch(() => {}); // Connected browser belongs to the operator; close disconnects this CDP client, not the browser server. await browser?.close(); diff --git a/e2e/tests/share-chain/http-evidence.test.ts b/e2e/tests/share-chain/http-evidence.test.ts new file mode 100644 index 0000000000..b42f8c7eb0 --- /dev/null +++ b/e2e/tests/share-chain/http-evidence.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { captureHttpRequest, captureHttpResponse, evidenceUrl, mayReadEvidenceBody } from '../../lib/playwright/share-chain/http-evidence.js'; +const request = (url: string) => ({ url: () => url, method: () => 'GET', resourceType: () => 'fetch' }); +const response = (url: string, json: () => Promise) => ({ url: () => url, status: () => 200, request: () => request(url), json }); +describe('share probe evidence (local helper tests, not live acceptance)', () => { + it('records directory attempts even when no response ever arrives', () => { + expect(captureHttpRequest(request('https://console.invalid/api/teams/t/members'), 'visitor')).toMatchObject({ page: 'visitor', method: 'GET', url: 'https://console.invalid/api/teams/t/members' }); + }); + it.each(['/api/auth/get-session', '/api/teams/t/members', '/api/projects/p/members/comments', '/api/projects/p/comments/members', '/api/v1/public/snapshots/s/files/index.html'])('does not read sensitive/unrelated body %s', async path => { + const json = vi.fn().mockResolvedValue({ private: 'must not be read' }); + expect(await captureHttpResponse(response('https://console.invalid' + path, json), 'visitor')).toMatchObject({ status: 200, body: null, bodyRead: 'not-requested' }); + expect(json).not.toHaveBeenCalled(); + }); + it('distinguishes an actual JSON null from a lost CDP body', async () => { + const url = 'https://console.invalid/api/v1/collab/share/alias/comments'; + expect(await captureHttpResponse(response(url, async () => null), 'visitor')).toMatchObject({ body: null, bodyRead: 'complete' }); + expect(await captureHttpResponse(response(url, async () => { throw new Error('Network.getResponseBody'); }), 'visitor')).toMatchObject({ body: null, bodyRead: 'unavailable' }); + }); + it('captures comment snapshots verbatim without interpreting identity', async () => { + const body = { comments: [{ id: 'test-id', author: { kind: 'member', displayName: 'Test snapshot' } }], latestSeq: 1, unresolvedTotal: 1 }; + const read = vi.fn().mockResolvedValue(body); + const pending = captureHttpResponse(response('https://console.invalid/api/v1/collab/share/alias/comments', read), 'visitor'); + expect(read).toHaveBeenCalledOnce(); + expect((await pending).body).toBe(body); + }); + it('allows canonical metadata/backfill/publish evidence paths', () => { + for (const path of ['/v1/public/snapshots/a', '/api/v1/public/snapshots/a', '/api/projects/p/comments/sync-state', '/api/projects/p/conversations/c/comments', '/api/projects/p/files/index.html/publish-public']) expect(mayReadEvidenceBody(path)).toBe(true); + }); + it('handles malformed URLs without persisting input or reading a body', async () => { + const json = vi.fn().mockResolvedValue('not allowed'); + expect(evidenceUrl('not a URL secret')).toBe('invalid:'); + expect(await captureHttpResponse(response('not a URL secret', json), 'visitor')).toMatchObject({ url: 'invalid:', bodyRead: 'not-requested' }); + expect(json).not.toHaveBeenCalled(); + }); + it('redacts credentials and arbitrary query values', () => { + const url = new URL(evidenceUrl('https://u:secret@console.invalid/api/x?token=secret&projectId=p#secret')); + expect(evidenceUrl('data:text/plain,secret')).toBe('data:'); + expect(url.username + url.password + url.hash).toBe(''); + expect(url.searchParams.get('token')).toBe(''); + expect(url.searchParams.get('projectId')).toBe('p'); + }); +});