From aa61a56a72dca337bff684e20da2a398f0d80cd5 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:11:42 -0400 Subject: [PATCH] test(mock): judge plain data through the owner The mock judged plainness five ways beside the engine's owner in src/plain-data.ts: two private isPlainObject copies (state.ts, request-body.ts), two inline checks (support.ts asObject, the chaos stripped arm), and the GraphQL variables guard in routes.ts, all accepting any non-null, non-array object, where the owner also refuses a foreign prototype. All five now use the owner; its predicate type is the type the mock's Json alias names, so no wrapper or cast is needed, and routes.ts drops its cast. A live_state seed can carry a foreign prototype after all: the yaml parser turns !!timestamp into a Date, !!set into a Set, and !!binary into a Uint8Array, and the schema passes them through as unknown. Under the owner such a seed replaces like a scalar, as the engine treats the same tag in a settings file, where the old checks spread it into the state. Three arbitrary-key copies assigned out[key], so a key spelled __proto__ set the output's prototype: deepMerge (under the stricter predicate reslugRepo then skipped the seeded owner), the chaos missing_envelope arm (the seeded key vanished from the reply), and protectionFromPut. All three write through the owner's put, and deepMerge reads through own; a test each pins deepMerge and the chaos arm with a parsed seed. --- test/e2e/mock/chaos.test.ts | 31 +++++++++++++++++++++++++++++++ test/e2e/mock/chaos.ts | 7 ++++--- test/e2e/mock/request-body.ts | 7 ++----- test/e2e/mock/routes.ts | 7 +++---- test/e2e/mock/state.test.ts | 12 ++++++++++++ test/e2e/mock/state.ts | 11 ++++------- test/e2e/mock/support.ts | 3 ++- 7 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 test/e2e/mock/chaos.test.ts diff --git a/test/e2e/mock/chaos.test.ts b/test/e2e/mock/chaos.test.ts new file mode 100644 index 000000000..e111875b2 --- /dev/null +++ b/test/e2e/mock/chaos.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { takeCorruption } from "./chaos.js"; + +describe("missing_envelope corruption", () => { + test("a body key named __proto__ stays data in the stripped reply, as a seeded repo can spell it, and the lists still go", () => { + // Parsed, not a literal: `__proto__` in an object literal sets the prototype. + const body = JSON.parse('{"name": "svc", "topics": ["a"], "__proto__": {"example": true}}'); + const result = takeCorruption( + "k", + { corrupt: { key: "k", mode: "missing_envelope" }, corruptCounts: new Map() }, + { status: 200, body }, + { method: "GET", pathname: "/repos/octocat/svc", query: "", status: 0 }, + ); + const stripped = result?.response.body as Record; + expect({ + status: result?.response.status, + offSpec: result?.offSpecBody, + proto: Object.getPrototypeOf(stripped), + keys: Object.getOwnPropertyNames(stripped), + ownKey: Object.getOwnPropertyDescriptor(stripped, "__proto__")?.value, + name: stripped.name, + }).toEqual({ + status: 200, + offSpec: true, + proto: Object.prototype, + keys: ["name", "__proto__"], + ownKey: { example: true }, + name: "svc", + }); + }); +}); diff --git a/test/e2e/mock/chaos.ts b/test/e2e/mock/chaos.ts index ba0f33b50..56e3bfc65 100644 --- a/test/e2e/mock/chaos.ts +++ b/test/e2e/mock/chaos.ts @@ -1,5 +1,6 @@ /** Fault and chaos-corruption injection; a scenario addresses a section endpoint or an inline core route by the same key. */ +import { isPlainObject, put } from "../../../src/plain-data.js"; import { ISSUE_REPORT_ENDPOINTS } from "../../../src/report/issue-report.js"; import { allEndpoints, allGraphqlOps } from "../../../src/sections/registry.js"; import type { @@ -192,11 +193,11 @@ function applyCorruption( return { response: { status: response.status, body: 42 }, log, offSpecBody: true }; } const body = response.body; - if (body && typeof body === "object" && !Array.isArray(body)) { + if (isPlainObject(body)) { const stripped: Json = {}; - for (const [entryKey, value] of Object.entries(body as Json)) { + for (const [entryKey, value] of Object.entries(body)) { if (!Array.isArray(value)) { - stripped[entryKey] = value; + put(stripped, entryKey, value); } } return { response: { status: response.status, body: stripped }, log, offSpecBody: true }; diff --git a/test/e2e/mock/request-body.ts b/test/e2e/mock/request-body.ts index a5d232157..a956af1af 100644 --- a/test/e2e/mock/request-body.ts +++ b/test/e2e/mock/request-body.ts @@ -5,9 +5,10 @@ * handler that stored the body verbatim would hide both from every scenario. */ +import { isPlainObject } from "../../../src/plain-data.js"; import type { Route } from "../../../src/sections/contract/endpoints.js"; import { sharedValidator } from "../openapi/validate.js"; -import type { Json, MockResponse } from "./support.js"; +import type { MockResponse } from "./support.js"; /** * Fields GitHub accepts (verified live) that its descriptor omits, so the spec-derived allowlist would @@ -19,10 +20,6 @@ export const UNDOCUMENTED_BODY_FIELDS: ReadonlyMap = n ["PATCH /repos/{owner}/{repo}", ["has_discussions"]], ]); -function isPlainObject(value: unknown): value is Json { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - /** * The body a handler receives for `route`: the documented top-level fields of an object body, every * other shape untouched (a raw string, an array, no body: the OpenAPI validator's concern). Nested diff --git a/test/e2e/mock/routes.ts b/test/e2e/mock/routes.ts index 044180cb7..8b5c56d4b 100644 --- a/test/e2e/mock/routes.ts +++ b/test/e2e/mock/routes.ts @@ -8,6 +8,7 @@ * -> denial barrier -> body allowlist -> handler -> response guard -> chaos hook */ +import { isPlainObject } from "../../../src/plain-data.js"; import type { SectionKey } from "../../../src/schema.js"; import { endpointPath, toleratedStatuses } from "../../../src/sections/contract/endpoints.js"; import { toleratedGraphqlErrors } from "../../../src/sections/contract/graphql.js"; @@ -170,15 +171,13 @@ export function handleGraphqlRequest( if ( typeof body.query !== "string" || typeof body.operationName !== "string" || - typeof body.variables !== "object" || - body.variables === null || - Array.isArray(body.variables) + !isPlainObject(body.variables) ) { return violation( "GraphQL request body must carry query (string), operationName (string), and variables (object)", ); } - const variables = body.variables as Json; + const variables = body.variables; const dispatched = graphqlOpForBody(body, ops); if (!dispatched) { diff --git a/test/e2e/mock/state.test.ts b/test/e2e/mock/state.test.ts index 243c9bb23..a1fdff972 100644 --- a/test/e2e/mock/state.test.ts +++ b/test/e2e/mock/state.test.ts @@ -33,6 +33,7 @@ import { environmentFromPut, invitationFromPut, LIST_MOCKS, + type LiveState, type MockState, normalizePinnedSeed, protectionFromPut, @@ -209,6 +210,17 @@ describe("buildState overlay semantics", () => { expect((third.repo.owner as Record).login).toBe("e2e-owner"); expect(third.repo.full_name).toBe("e2e-owner/e2e-repo"); }); + + test("a seed key named __proto__ is data, as a YAML file can spell it: an own key after the merge, the owner still reslugged", () => { + // Parsed, not a literal: `__proto__` in an object literal sets the prototype. + const seed = JSON.parse('{"repo": {"owner": {"__proto__": {"example": true}}}}') as LiveState; + const owner = buildState(seed, "org", "other-owner/svc").repo.owner as Record; + expect({ + proto: Object.getPrototypeOf(owner), + ownKey: Object.getOwnPropertyDescriptor(owner, "__proto__")?.value, + login: owner.login, + }).toEqual({ proto: Object.prototype, ownKey: { example: true }, login: "other-owner" }); + }); }); describe("protectionFromPut round trip", () => { diff --git a/test/e2e/mock/state.ts b/test/e2e/mock/state.ts index a59e3b708..24c2e913f 100644 --- a/test/e2e/mock/state.ts +++ b/test/e2e/mock/state.ts @@ -9,6 +9,7 @@ * collaboratorFromPut -> role_name via roleForPermission, the same map the section runs on its declaration */ +import { isPlainObject, own, put } from "../../../src/plain-data.js"; import { GRAPHQL_BOOLEAN_TWINS, GRAPHQL_REVIEW_TWINS, @@ -380,10 +381,6 @@ export function normalizePinnedSeed( return pins.sort((a, b) => a.position - b.position); } -function isPlainObject(value: unknown): value is Json { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - /** * Runs last in buildState, after the repo is re-slugged and `state.slug` is fixed: the slug is part of * every id, so an id minted earlier would name the fixture. Write handlers mint with the same codec @@ -418,8 +415,8 @@ export function restRepoSurface(repo: Json): Json { function deepMerge(base: Json, overlay: Json): Json { const out: Json = { ...base }; for (const [key, value] of Object.entries(overlay)) { - const prev = out[key]; - out[key] = isPlainObject(prev) && isPlainObject(value) ? deepMerge(prev, value) : value; + const prev = own(out, key); + put(out, key, isPlainObject(prev) && isPlainObject(value) ? deepMerge(prev, value) : value); } return out; } @@ -1029,7 +1026,7 @@ export function protectionFromPut(payload: Json): Json { break; } default: - out[key] = value; + put(out, key, value); } } return out; diff --git a/test/e2e/mock/support.ts b/test/e2e/mock/support.ts index 558e46f5c..4832ae7a2 100644 --- a/test/e2e/mock/support.ts +++ b/test/e2e/mock/support.ts @@ -5,6 +5,7 @@ * of them, so a fragment can depend on it without pulling the whole pipeline in. */ +import { isPlainObject } from "../../../src/plain-data.js"; import type { SectionKey } from "../../../src/schema.js"; import { type DefinitiveRejection, @@ -137,7 +138,7 @@ function clampInt(raw: string | undefined, fallback: number): number { // --- Handler helpers ------------------------------------------------------ export function asObject(body: unknown): Json { - return body && typeof body === "object" && !Array.isArray(body) ? (body as Json) : {}; + return isPlainObject(body) ? body : {}; } export function ok(body: unknown): MockResponse {