Skip to content

Commit aa61a56

Browse files
committed
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.
1 parent 858c7cd commit aa61a56

7 files changed

Lines changed: 58 additions & 20 deletions

File tree

‎test/e2e/mock/chaos.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { takeCorruption } from "./chaos.js";
3+
4+
describe("missing_envelope corruption", () => {
5+
test("a body key named __proto__ stays data in the stripped reply, as a seeded repo can spell it, and the lists still go", () => {
6+
// Parsed, not a literal: `__proto__` in an object literal sets the prototype.
7+
const body = JSON.parse('{"name": "svc", "topics": ["a"], "__proto__": {"example": true}}');
8+
const result = takeCorruption(
9+
"k",
10+
{ corrupt: { key: "k", mode: "missing_envelope" }, corruptCounts: new Map() },
11+
{ status: 200, body },
12+
{ method: "GET", pathname: "/repos/octocat/svc", query: "", status: 0 },
13+
);
14+
const stripped = result?.response.body as Record<string, unknown>;
15+
expect({
16+
status: result?.response.status,
17+
offSpec: result?.offSpecBody,
18+
proto: Object.getPrototypeOf(stripped),
19+
keys: Object.getOwnPropertyNames(stripped),
20+
ownKey: Object.getOwnPropertyDescriptor(stripped, "__proto__")?.value,
21+
name: stripped.name,
22+
}).toEqual({
23+
status: 200,
24+
offSpec: true,
25+
proto: Object.prototype,
26+
keys: ["name", "__proto__"],
27+
ownKey: { example: true },
28+
name: "svc",
29+
});
30+
});
31+
});

‎test/e2e/mock/chaos.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** Fault and chaos-corruption injection; a scenario addresses a section endpoint or an inline core route by the same key. */
22

3+
import { isPlainObject, put } from "../../../src/plain-data.js";
34
import { ISSUE_REPORT_ENDPOINTS } from "../../../src/report/issue-report.js";
45
import { allEndpoints, allGraphqlOps } from "../../../src/sections/registry.js";
56
import type {
@@ -192,11 +193,11 @@ function applyCorruption(
192193
return { response: { status: response.status, body: 42 }, log, offSpecBody: true };
193194
}
194195
const body = response.body;
195-
if (body && typeof body === "object" && !Array.isArray(body)) {
196+
if (isPlainObject(body)) {
196197
const stripped: Json = {};
197-
for (const [entryKey, value] of Object.entries(body as Json)) {
198+
for (const [entryKey, value] of Object.entries(body)) {
198199
if (!Array.isArray(value)) {
199-
stripped[entryKey] = value;
200+
put(stripped, entryKey, value);
200201
}
201202
}
202203
return { response: { status: response.status, body: stripped }, log, offSpecBody: true };

‎test/e2e/mock/request-body.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
* handler that stored the body verbatim would hide both from every scenario.
66
*/
77

8+
import { isPlainObject } from "../../../src/plain-data.js";
89
import type { Route } from "../../../src/sections/contract/endpoints.js";
910
import { sharedValidator } from "../openapi/validate.js";
10-
import type { Json, MockResponse } from "./support.js";
11+
import type { MockResponse } from "./support.js";
1112

1213
/**
1314
* 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<Route, readonly string[]> = n
1920
["PATCH /repos/{owner}/{repo}", ["has_discussions"]],
2021
]);
2122

22-
function isPlainObject(value: unknown): value is Json {
23-
return value !== null && typeof value === "object" && !Array.isArray(value);
24-
}
25-
2623
/**
2724
* The body a handler receives for `route`: the documented top-level fields of an object body, every
2825
* other shape untouched (a raw string, an array, no body: the OpenAPI validator's concern). Nested

‎test/e2e/mock/routes.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* -> denial barrier -> body allowlist -> handler -> response guard -> chaos hook
99
*/
1010

11+
import { isPlainObject } from "../../../src/plain-data.js";
1112
import type { SectionKey } from "../../../src/schema.js";
1213
import { endpointPath, toleratedStatuses } from "../../../src/sections/contract/endpoints.js";
1314
import { toleratedGraphqlErrors } from "../../../src/sections/contract/graphql.js";
@@ -170,15 +171,13 @@ export function handleGraphqlRequest(
170171
if (
171172
typeof body.query !== "string" ||
172173
typeof body.operationName !== "string" ||
173-
typeof body.variables !== "object" ||
174-
body.variables === null ||
175-
Array.isArray(body.variables)
174+
!isPlainObject(body.variables)
176175
) {
177176
return violation(
178177
"GraphQL request body must carry query (string), operationName (string), and variables (object)",
179178
);
180179
}
181-
const variables = body.variables as Json;
180+
const variables = body.variables;
182181

183182
const dispatched = graphqlOpForBody(body, ops);
184183
if (!dispatched) {

‎test/e2e/mock/state.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
environmentFromPut,
3434
invitationFromPut,
3535
LIST_MOCKS,
36+
type LiveState,
3637
type MockState,
3738
normalizePinnedSeed,
3839
protectionFromPut,
@@ -209,6 +210,17 @@ describe("buildState overlay semantics", () => {
209210
expect((third.repo.owner as Record<string, unknown>).login).toBe("e2e-owner");
210211
expect(third.repo.full_name).toBe("e2e-owner/e2e-repo");
211212
});
213+
214+
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", () => {
215+
// Parsed, not a literal: `__proto__` in an object literal sets the prototype.
216+
const seed = JSON.parse('{"repo": {"owner": {"__proto__": {"example": true}}}}') as LiveState;
217+
const owner = buildState(seed, "org", "other-owner/svc").repo.owner as Record<string, unknown>;
218+
expect({
219+
proto: Object.getPrototypeOf(owner),
220+
ownKey: Object.getOwnPropertyDescriptor(owner, "__proto__")?.value,
221+
login: owner.login,
222+
}).toEqual({ proto: Object.prototype, ownKey: { example: true }, login: "other-owner" });
223+
});
212224
});
213225

214226
describe("protectionFromPut round trip", () => {

‎test/e2e/mock/state.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* collaboratorFromPut -> role_name via roleForPermission, the same map the section runs on its declaration
1010
*/
1111

12+
import { isPlainObject, own, put } from "../../../src/plain-data.js";
1213
import {
1314
GRAPHQL_BOOLEAN_TWINS,
1415
GRAPHQL_REVIEW_TWINS,
@@ -380,10 +381,6 @@ export function normalizePinnedSeed(
380381
return pins.sort((a, b) => a.position - b.position);
381382
}
382383

383-
function isPlainObject(value: unknown): value is Json {
384-
return typeof value === "object" && value !== null && !Array.isArray(value);
385-
}
386-
387384
/**
388385
* Runs last in buildState, after the repo is re-slugged and `state.slug` is fixed: the slug is part of
389386
* 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 {
418415
function deepMerge(base: Json, overlay: Json): Json {
419416
const out: Json = { ...base };
420417
for (const [key, value] of Object.entries(overlay)) {
421-
const prev = out[key];
422-
out[key] = isPlainObject(prev) && isPlainObject(value) ? deepMerge(prev, value) : value;
418+
const prev = own(out, key);
419+
put(out, key, isPlainObject(prev) && isPlainObject(value) ? deepMerge(prev, value) : value);
423420
}
424421
return out;
425422
}
@@ -1029,7 +1026,7 @@ export function protectionFromPut(payload: Json): Json {
10291026
break;
10301027
}
10311028
default:
1032-
out[key] = value;
1029+
put(out, key, value);
10331030
}
10341031
}
10351032
return out;

‎test/e2e/mock/support.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* of them, so a fragment can depend on it without pulling the whole pipeline in.
66
*/
77

8+
import { isPlainObject } from "../../../src/plain-data.js";
89
import type { SectionKey } from "../../../src/schema.js";
910
import {
1011
type DefinitiveRejection,
@@ -137,7 +138,7 @@ function clampInt(raw: string | undefined, fallback: number): number {
137138
// --- Handler helpers ------------------------------------------------------
138139

139140
export function asObject(body: unknown): Json {
140-
return body && typeof body === "object" && !Array.isArray(body) ? (body as Json) : {};
141+
return isPlainObject(body) ? body : {};
141142
}
142143

143144
export function ok(body: unknown): MockResponse {

0 commit comments

Comments
 (0)