Require observed request method and initiator metadata instead of inventing defaults. Preserve valid redirect history while excluding later events for malformed reused IDs. Keep unavailable or invalid encoded lengths unknown and retain valid zero values. Validation: 57 CDP boundary tests plus temporary-directory hygiene and combined checks. Protocol events come from the fake CDP server; no live browser verification is claimed.
617 lines
19 KiB
TypeScript
617 lines
19 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
|
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
|
|
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
|
import { trackBrowser } from "./cdpBrowserProvider.support.js";
|
|
|
|
it("preserves unavailable response observations and captures completed peers", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandEvents(command, origin) {
|
|
if (command.method !== "Network.enable") return undefined;
|
|
return ["unfinished", "missing-body", "available"].flatMap(
|
|
(requestId) => {
|
|
const session =
|
|
command.sessionId === undefined
|
|
? {}
|
|
: { sessionId: command.sessionId };
|
|
const url = `${origin}/${requestId}`;
|
|
return [
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId,
|
|
type: "Fetch",
|
|
request: {
|
|
url,
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
postData: '{"selected":true}',
|
|
},
|
|
initiator: { type: "script" },
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.responseReceived",
|
|
params: {
|
|
requestId,
|
|
response: {
|
|
url,
|
|
status: 200,
|
|
mimeType: "application/json",
|
|
headers: {},
|
|
},
|
|
},
|
|
},
|
|
...(requestId === "unfinished"
|
|
? []
|
|
: [
|
|
{
|
|
...session,
|
|
method: "Network.loadingFinished",
|
|
params: { requestId, encodedDataLength: 0 },
|
|
},
|
|
]),
|
|
];
|
|
},
|
|
);
|
|
},
|
|
commandError(command) {
|
|
if (
|
|
command.method === "Network.getResponseBody" &&
|
|
command.params.requestId === "missing-body"
|
|
)
|
|
return {
|
|
code: -32_000,
|
|
message: "No data found for resource with given identifier",
|
|
};
|
|
return undefined;
|
|
},
|
|
commandResult(command) {
|
|
return command.method === "Network.getResponseBody"
|
|
? { body: '{"retained":true}', base64Encoded: false }
|
|
: undefined;
|
|
},
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 20,
|
|
include_json_body_shapes: true,
|
|
}),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.network.requests).toEqual([
|
|
expect.objectContaining({
|
|
request_id: "unfinished",
|
|
status: 200,
|
|
encoded_data_length: null,
|
|
body_shapes: {
|
|
status: "partial",
|
|
request: expect.any(Object),
|
|
response: null,
|
|
},
|
|
}),
|
|
expect.objectContaining({
|
|
request_id: "missing-body",
|
|
status: 200,
|
|
encoded_data_length: 0,
|
|
body_shapes: {
|
|
status: "partial",
|
|
request: expect.any(Object),
|
|
response: null,
|
|
},
|
|
}),
|
|
expect.objectContaining({
|
|
request_id: "available",
|
|
body_shapes: {
|
|
status: "included",
|
|
request: expect.any(Object),
|
|
response: expect.any(Object),
|
|
},
|
|
}),
|
|
]);
|
|
expect(result.value.completeness.unavailable_sections).toContain(
|
|
"json_body_shapes",
|
|
);
|
|
expect(result.value.limitations).toContainEqual(
|
|
expect.stringContaining(
|
|
"missing-body: CDP Network.getResponseBody failed (-32000): No data found",
|
|
),
|
|
);
|
|
expect(
|
|
browser.commands
|
|
.filter(({ method }) => method === "Network.getResponseBody")
|
|
.map(({ params }) => params.requestId),
|
|
).toEqual(["missing-body", "available"]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
options: {
|
|
commandError: (command: { method: string }) =>
|
|
command.method === "Network.getResponseBody"
|
|
? { code: -32_602, message: "Invalid parameters" }
|
|
: undefined,
|
|
},
|
|
reason: "protocol_error",
|
|
},
|
|
{
|
|
options: { malformedMessageOnMethod: "Network.getResponseBody" },
|
|
reason: "protocol_error",
|
|
},
|
|
{
|
|
options: { closeOnMethod: "Network.getResponseBody" },
|
|
reason: "disconnected",
|
|
},
|
|
])(
|
|
"preserves fatal response capture errors ($reason)",
|
|
async ({ options, reason }) => {
|
|
const browser = await startFakeCdpBrowser(options);
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 20,
|
|
include_json_body_shapes: true,
|
|
}),
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason },
|
|
});
|
|
},
|
|
);
|
|
|
|
describe("malformed CDP network request metadata", () => {
|
|
it("omits requests with missing required metadata and reports the malformed events", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandEvents(command, origin) {
|
|
if (command.method !== "Network.enable") return undefined;
|
|
const session =
|
|
command.sessionId === undefined
|
|
? {}
|
|
: { sessionId: command.sessionId };
|
|
return [
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "valid",
|
|
request: { url: `${origin}/valid`, method: "GET" },
|
|
initiator: { type: "future-protocol-type" },
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "missing-method",
|
|
request: { url: `${origin}/missing-method` },
|
|
initiator: { type: "script" },
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "empty-method",
|
|
request: { url: `${origin}/empty-method`, method: "" },
|
|
initiator: { type: "script" },
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "missing-initiator-type",
|
|
request: {
|
|
url: `${origin}/missing-initiator-type`,
|
|
method: "POST",
|
|
},
|
|
initiator: {},
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "missing-request",
|
|
request: null,
|
|
initiator: { type: "script" },
|
|
},
|
|
},
|
|
];
|
|
},
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.network.requests).toEqual([
|
|
expect.objectContaining({
|
|
request_id: "valid",
|
|
method: "GET",
|
|
initiator: expect.objectContaining({ type: "future-protocol-type" }),
|
|
}),
|
|
]);
|
|
expect(result.value.completeness.excluded).toContainEqual({
|
|
section: "network_requests",
|
|
reason: "invalid_protocol_value",
|
|
count: 4,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("malformed reused CDP request IDs", () => {
|
|
it("keeps the last valid request and redirects after a malformed replacement", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandEvents(command, origin) {
|
|
if (command.method !== "Network.enable") return undefined;
|
|
const session =
|
|
command.sessionId === undefined
|
|
? {}
|
|
: { sessionId: command.sessionId };
|
|
return [
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "redirecting",
|
|
request: { url: `${origin}/first`, method: "POST" },
|
|
initiator: { type: "parser" },
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "redirecting",
|
|
request: { url: `${origin}/second`, method: "GET" },
|
|
initiator: { type: "other" },
|
|
redirectResponse: {
|
|
url: `${origin}/first`,
|
|
status: 301,
|
|
mimeType: "text/plain",
|
|
encodedDataLength: 12,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.responseReceived",
|
|
params: {
|
|
requestId: "redirecting",
|
|
response: {
|
|
url: `${origin}/second`,
|
|
status: 202,
|
|
mimeType: "application/json",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "redirecting",
|
|
request: { url: `${origin}/third` },
|
|
initiator: { type: "other" },
|
|
redirectResponse: {
|
|
url: `${origin}/second`,
|
|
status: 302,
|
|
mimeType: "text/plain",
|
|
encodedDataLength: 18,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.responseReceived",
|
|
params: {
|
|
requestId: "redirecting",
|
|
response: {
|
|
url: `${origin}/third`,
|
|
status: 418,
|
|
mimeType: "application/json",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.loadingFinished",
|
|
params: { requestId: "redirecting", encodedDataLength: 777 },
|
|
},
|
|
];
|
|
},
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
include_json_body_shapes: true,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.network.requests).toEqual([
|
|
expect.objectContaining({
|
|
request_id: "redirecting",
|
|
url: `${browser.allowedOrigin}/second`,
|
|
method: "GET",
|
|
status: 202,
|
|
mime_type: "application/json",
|
|
encoded_data_length: null,
|
|
redirects: [
|
|
expect.objectContaining({
|
|
url: `${browser.allowedOrigin}/first`,
|
|
response_url: `${browser.allowedOrigin}/first`,
|
|
method: "POST",
|
|
status: 301,
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
expect(result.value.completeness.excluded).toContainEqual({
|
|
section: "network_requests",
|
|
reason: "invalid_protocol_value",
|
|
count: 1,
|
|
});
|
|
expect(result.value.completeness.excluded).toContainEqual({
|
|
section: "json_body_shapes",
|
|
reason: "invalid_protocol_value",
|
|
count: 1,
|
|
});
|
|
expect(
|
|
browser.commands.filter(
|
|
({ method }) => method === "Network.getResponseBody",
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("CDP network length metadata", () => {
|
|
it("retains valid zero lengths and excludes malformed loading lengths", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandEvents(command, origin) {
|
|
if (command.method !== "Network.enable") return undefined;
|
|
const session =
|
|
command.sessionId === undefined
|
|
? {}
|
|
: { sessionId: command.sessionId };
|
|
const request = (requestId: string, path: string) => ({
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId,
|
|
request: { url: `${origin}${path}`, method: "GET" },
|
|
initiator: { type: "parser" },
|
|
},
|
|
});
|
|
return [
|
|
request("zero", "/zero"),
|
|
request("missing", "/missing"),
|
|
request("negative", "/negative"),
|
|
request("prior", "/prior"),
|
|
request("redirect-zero", "/redirect-zero-first"),
|
|
request("redirect-invalid", "/redirect-invalid-first"),
|
|
{
|
|
...session,
|
|
method: "Network.loadingFinished",
|
|
params: { requestId: "zero", encodedDataLength: 0 },
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.loadingFinished",
|
|
params: { requestId: "missing" },
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.loadingFinished",
|
|
params: { requestId: "negative", encodedDataLength: -1 },
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.loadingFinished",
|
|
params: { requestId: "prior", encodedDataLength: 27 },
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.loadingFinished",
|
|
params: { requestId: "prior", encodedDataLength: -1 },
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "redirect-zero",
|
|
request: { url: `${origin}/redirect-zero-final`, method: "GET" },
|
|
initiator: { type: "parser" },
|
|
redirectResponse: {
|
|
url: `${origin}/redirect-zero-first`,
|
|
status: 301,
|
|
encodedDataLength: 0,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
...session,
|
|
method: "Network.requestWillBeSent",
|
|
params: {
|
|
requestId: "redirect-invalid",
|
|
request: {
|
|
url: `${origin}/redirect-invalid-final`,
|
|
method: "GET",
|
|
},
|
|
initiator: { type: "parser" },
|
|
redirectResponse: {
|
|
url: `${origin}/redirect-invalid-first`,
|
|
status: 301,
|
|
encodedDataLength: -1,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
},
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
const requests = new Map(
|
|
result.value.network.requests.map((item) => [item.request_id, item]),
|
|
);
|
|
expect(requests.get("zero")?.encoded_data_length).toBe(0);
|
|
expect(requests.get("missing")?.encoded_data_length).toBeNull();
|
|
expect(requests.get("negative")?.encoded_data_length).toBeNull();
|
|
expect(requests.get("prior")?.encoded_data_length).toBe(27);
|
|
expect(
|
|
requests.get("redirect-zero")?.redirects?.[0]?.encoded_data_length,
|
|
).toBe(0);
|
|
expect(
|
|
requests.get("redirect-invalid")?.redirects?.[0]?.encoded_data_length,
|
|
).toBeNull();
|
|
expect(result.value.completeness.excluded).toContainEqual({
|
|
section: "network_requests",
|
|
reason: "invalid_protocol_value",
|
|
count: 4,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("same-origin redirect chains", () => {
|
|
it("retains same-origin redirect hops on the one final request", async () => {
|
|
const browser = await startFakeCdpBrowser({ redirectWithinOrigin: true });
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.network.requests).toHaveLength(1);
|
|
expect(result.value.network.requests[0]).toMatchObject({
|
|
request_id: "request-1",
|
|
url: `${browser.allowedOrigin}/redirected?token=final-secret`,
|
|
status: 200,
|
|
redirects: [
|
|
{
|
|
url: `${browser.allowedOrigin}/api?token=network-secret`,
|
|
response_url: `${browser.allowedOrigin}/api?token=network-secret`,
|
|
method: "POST",
|
|
resource_type: "Fetch",
|
|
status: 301,
|
|
mime_type: "text/plain",
|
|
encoded_data_length: 23,
|
|
request_timestamp: 7,
|
|
redirect_event_timestamp: 8,
|
|
},
|
|
{
|
|
url: `${browser.allowedOrigin}/intermediate`,
|
|
response_url: `${browser.allowedOrigin}/intermediate`,
|
|
method: "GET",
|
|
resource_type: "Fetch",
|
|
status: 302,
|
|
mime_type: "text/plain",
|
|
encoded_data_length: 27,
|
|
request_timestamp: 8,
|
|
redirect_event_timestamp: 9,
|
|
},
|
|
],
|
|
});
|
|
expect(JSON.stringify(result.value.network.requests)).not.toContain(
|
|
"redirect-header-secret",
|
|
);
|
|
expect(result.value.completeness.excluded).not.toContainEqual({
|
|
section: "network_requests",
|
|
reason: "invalid_protocol_value",
|
|
count: expect.any(Number),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("redirect origin policy", () => {
|
|
it("drops network evidence when a request redirects outside the approved origin", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
redirectToDisallowedOrigin: true,
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.network.requests).toEqual([]);
|
|
});
|
|
|
|
it("does not retain excluded prior-hop data when a redirect returns in scope", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
redirectFromDisallowedOrigin: true,
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.network.requests).toHaveLength(1);
|
|
expect(result.value.network.requests[0]).toMatchObject({
|
|
url: `${browser.allowedOrigin}/returned`,
|
|
redirects: [],
|
|
});
|
|
const output = JSON.stringify(result.value.network.requests);
|
|
expect(output).not.toContain("private.example.test");
|
|
expect(output).not.toContain("redirect-header-secret");
|
|
expect(output).not.toContain("redirect-body-secret");
|
|
expect(result.value.completeness.policy_filtered_sections).toContain(
|
|
"network_requests",
|
|
);
|
|
expect(result.value.completeness.status).toBe("policy_filtered");
|
|
expect(result.value.completeness.unavailable_sections).not.toContain(
|
|
"network_requests",
|
|
);
|
|
expect(result.value.completeness.excluded).not.toContainEqual({
|
|
section: "network_requests",
|
|
reason: "invalid_protocol_value",
|
|
count: expect.any(Number),
|
|
});
|
|
});
|
|
});
|