rea/tests/boundary/browser/cdpBrowserProvider.storage_...

356 lines
11 KiB
TypeScript

import { expect, it } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
import type { WebPageInspection } from "../../../src/domain/browserObservationSchemas.js";
import { compareWebCaptures } from "../../../src/domain/webCaptureDiff.js";
import { compareWebCapturesInputSchema } from "../../../src/domain/webCaptureDiffSchemas.js";
import {
startFakeCdpBrowser,
type FakeOptions,
} from "../../fixtures/fakeCdpBrowser.js";
import { trackBrowser } from "./cdpBrowserProvider.support.js";
const inspect = async (options: FakeOptions, includeFingerprints = true) => {
const browser = await startFakeCdpBrowser(options);
trackBrowser(browser);
const input = inspectWebPageInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
include_storage_keys: true,
include_storage_fingerprints: includeFingerprints,
});
const capture = async () => {
const result = await new CdpBrowserProvider().inspectPage(input);
if (!result.ok) throw result.error;
return result.value;
};
return { capture };
};
const compareStorage = (before: WebPageInspection, after: WebPageInspection) =>
compareWebCaptures(
compareWebCapturesInputSchema.parse({
before: { inspection: before },
after: { inspection: after },
}),
).dimensions.storage;
it.each([
["CacheStorage.requestCacheNames", {}],
["CacheStorage.requestCacheNames", { caches: null }],
["CacheStorage.requestCacheNames", { caches: [null] }],
[
"CacheStorage.requestCacheNames",
{ caches: [{ cacheName: "assets-v1", cacheId: "secret-id" }, null] },
],
["CacheStorage.requestCacheNames", { caches: [{ cacheName: "assets-v1" }] }],
["IndexedDB.requestDatabaseNames", { databaseNames: [null] }],
["DOMStorage.getDOMStorageItems", { entries: [null] }],
] as const)(
"preserves unknown inventory coverage from %s: %j",
async (method, reply) => {
let malformed = false;
const { capture } = await inspect({
commandResult: (command) =>
malformed && command.method === method ? reply : undefined,
});
const before = await capture();
malformed = true;
const after = await capture();
expect(after.storage.fingerprints_complete).toBe(false);
expect(after.completeness.unavailable_sections).toContain("storage_keys");
expect(after.completeness.excluded).toContainEqual({
section: "storage_keys",
reason: "invalid_protocol_value",
count: null,
});
expect(compareStorage(before, after)).toMatchObject({
status: "unknown",
total_changes: 0,
});
},
);
it("preserves unavailable storage-key coverage without requesting fingerprints", async () => {
const { capture } = await inspect(
{ unsupportedMethods: ["CacheStorage.requestCacheNames"] },
false,
);
const captured = await capture();
expect(captured.completeness.excluded).toContainEqual({
section: "storage_keys",
reason: "provider_unavailable",
count: null,
});
expect(captured.storage.local_storage_keys).toEqual(["public-key"]);
});
const validKey = { type: "string", value: "row-1" };
it.each(["unavailable", "malformed"] as const)(
"compares shared complete values with %s cache inventory",
async (inventory) => {
let entries: unknown[][] = [["shared-key", "before"]];
const { capture } = await inspect({
...(inventory === "unavailable"
? { unsupportedMethods: ["CacheStorage.requestCacheNames"] }
: {}),
commandResult: (command) => {
if (command.method === "CacheStorage.requestCacheNames")
return { caches: [null] };
if (command.method === "DOMStorage.getDOMStorageItems")
return { entries };
return undefined;
},
});
const before = await capture();
expect(before.completeness.unavailable_sections).toContain("storage_keys");
expect(before.storage.fingerprints_complete).toBe(false);
expect(compareStorage(before, await capture())).toMatchObject({
status: "unknown",
total_changes: 0,
});
entries = [["shared-key", "after"]];
const changed = await capture();
expect(changed.storage.usage_bytes).toBe(before.storage.usage_bytes);
expect(changed.storage.quota_bytes).toBe(before.storage.quota_bytes);
for (const [left, right] of [
[before, changed],
[changed, before],
] as const)
expect(compareStorage(left, right)).toMatchObject({
status: "changed",
total_changes: 2,
changes: [
expect.objectContaining({ change: "modified" }),
expect.objectContaining({ change: "modified" }),
],
});
const incompleteValues = structuredClone(changed);
for (const fingerprint of incompleteValues.storage.content_fingerprints)
if (["local_storage", "session_storage"].includes(fingerprint.scope))
fingerprint.complete = false;
expect(compareStorage(before, incompleteValues)).toMatchObject({
status: "unknown",
total_changes: 0,
});
// Missing or invalid peer values establish neither removal nor addition.
for (const partial of [[], [["shared-key", null]]]) {
entries = partial;
const incomplete = await capture();
for (const [left, right] of [
[before, incomplete],
[incomplete, before],
] as const)
expect(compareStorage(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
}
},
);
it.each(["key", "primaryKey"])(
"requires IndexedDB %s identity evidence",
async (field) => {
const { capture } = await inspect({
commandResult: (command) =>
command.method === "IndexedDB.requestData"
? {
objectStoreDataEntries: [
{
key: validKey,
primaryKey: validKey,
value: validKey,
[field]: {},
},
],
hasMore: false,
}
: undefined,
});
const captured = await capture();
expect(captured.storage.fingerprints_complete).toBe(false);
expect(
captured.storage.content_fingerprints.find(
({ scope }) => scope === "indexed_db_record",
)?.complete,
).toBe(false);
},
);
it("does not fingerprint a filtered partial object-store schema as complete", async () => {
let malformed = false;
const store = {
name: "records",
keyPath: { type: "string", string: "id" },
autoIncrement: false,
indexes: [],
};
const { capture } = await inspect({
commandResult: (command) =>
command.method === "IndexedDB.requestDatabase"
? {
databaseWithObjectStores: {
name: "app-db",
version: 1,
objectStores: [
store,
malformed ? null : { ...store, name: "other" },
],
},
}
: undefined,
});
const before = await capture();
malformed = true;
const after = await capture();
expect(
after.storage.content_fingerprints.find(
({ scope }) => scope === "indexed_db_schema",
)?.complete,
).toBe(false);
expect(compareStorage(before, after)).toMatchObject({
status: "unknown",
total_changes: 0,
});
});
const incompleteRemoteObjects = [
undefined,
null,
"raw",
{},
{ type: "string" },
{ type: "string", value: 1 },
{ type: "boolean", value: "false" },
{ type: "number" },
{ type: "number", unserializableValue: "invalid" },
{ type: "number", unserializableValue: ["NaN"] },
{ type: "function", description: "function f() {}" },
{ type: "symbol", description: "Symbol(x)" },
{ type: "object", subtype: "null" },
{ type: "bigint", unserializableValue: "broken" },
] as const;
const completeRemoteObjects = [
{ type: "string", value: "" },
{ type: "boolean", value: false },
{ type: "number", value: 0 },
{ type: "undefined" },
{ type: "number", unserializableValue: "NaN" },
{ type: "number", unserializableValue: "-0" },
{ type: "bigint", unserializableValue: "-42n" },
{ type: "object", subtype: "null", value: null },
{ type: "object", value: { nested: [false, null, 0] } },
] as const;
it.each([
...incompleteRemoteObjects.map((value) => [value, false] as const),
...completeRemoteObjects.map((value) => [value, true] as const),
])(
"validates IndexedDB RemoteObject completeness: %j => %s",
async (value, complete) => {
const { capture } = await inspect({
commandResult: (command) =>
command.method === "IndexedDB.requestData"
? {
objectStoreDataEntries: [
{ key: validKey, primaryKey: validKey, value },
],
hasMore: false,
}
: undefined,
});
const before = await capture();
const after = await capture();
const records = before.storage.content_fingerprints.filter(
({ scope }) => scope === "indexed_db_record",
);
expect(records).toHaveLength(1);
expect(records[0]?.complete).toBe(complete);
expect(before.storage.fingerprints_complete).toBe(complete);
expect(compareStorage(before, after)).toMatchObject({
status: complete ? "unchanged" : "unknown",
total_changes: 0,
});
},
);
it.each([
[undefined, false],
[{}, false],
[{ type: "string" }, false],
[{ type: "array", array: [null] }, false],
[{ type: "null" }, true],
[{ type: "string", string: "id" }, true],
[{ type: "array", array: ["id", "name"] }, true],
] as const)(
"requires complete IndexedDB key paths: %j",
async (keyPath, complete) => {
const { capture } = await inspect({
commandResult: (command) =>
command.method === "IndexedDB.requestDatabase"
? {
databaseWithObjectStores: {
name: "app-db",
version: 1,
objectStores: [
{
name: "records",
keyPath,
autoIncrement: false,
indexes: [
{
name: "by-id",
keyPath,
unique: false,
multiEntry: false,
},
],
},
],
},
}
: undefined,
});
const captured = await capture();
expect(
captured.storage.content_fingerprints.find(
({ scope }) => scope === "indexed_db_schema",
)?.complete,
).toBe(complete);
expect(captured.storage.fingerprints_complete).toBe(complete);
},
);
it("accepts genuinely empty inventories as complete", async () => {
const { capture } = await inspect({
commandResult: (command) => {
switch (command.method) {
case "CacheStorage.requestCacheNames":
return { caches: [] };
case "IndexedDB.requestDatabaseNames":
return { databaseNames: [] };
case "DOMStorage.getDOMStorageItems":
return { entries: [] };
case "Network.getCookies":
return { cookies: [] };
default:
return undefined;
}
},
});
const before = await capture();
const after = await capture();
expect(before.storage.fingerprints_complete).toBe(true);
expect(compareStorage(before, after).status).toBe("unchanged");
});