Files
rea/src/application/environment/environmentStateWriteRefusals.test.ts
T
Edgar ArakelyanandClaude Opus 5.5 2086da6cc7 fix(environment): make the write gate and serving agree on lines, rules, inserts and documents
Five places where the gate approved a write serving would answer
differently:
- list and document reads are matched to checked lines as serving
  matches calls (path and query values decoded, the query in any
  order), so a reordered query no longer hides a contradiction;
- an increment or slug applies to every call, so a plan is
  unexplained-change when one call changes such a field and another
  leaves it as it was, in either order;
- an item read confirming a create no longer stops a later list from
  choosing another insert candidate; only a list check fixes the rule;
- the gate runs serving's document body check (accepted members, JSON
  types from the held and initial document), so a recorded write that
  serving would refuse leaves the plan unserved;
- a served create decodes its path values from the collection the call
  names, as the gate decoded the recorded path, whichever spelling the
  call used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:12:34 -07:00

223 lines
7.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { TimedCaptureRow } from "../../../tests/fixtures/analysisView.js";
import {
servedFrom,
type Served,
} from "../../../tests/fixtures/environmentStateServing.js";
import {
FIRST_BOOK,
SECOND_BOOK,
renamedBook,
type Book,
} from "../../../tests/fixtures/environmentStateWrites.js";
import { MAX_JSON_DEPTH, type JsonValue } from "../../domain/jsonValue.js";
import type { ReplayArguments } from "./environmentReplay.js";
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
const DARK = { theme: "dark", meta: { n: 1 } };
const LIGHT = { theme: "light", meta: { n: 2 } };
/** One organisation's settings document, read, patched with both members and read again. */
const SETTINGS_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/orgs/1/settings", DARK],
["PATCH", "/api/orgs/1/settings", LIGHT, 200, LIGHT],
["GET", "/api/orgs/1/settings", LIGHT],
];
const READ_SETTINGS = "get_orgs_settings_by_id";
const PATCH_SETTINGS = "patch_orgs_settings_by_id";
/** Every refused call is a tool error naming its problem, and leaves the state as it was. */
const expectRefusedUnchanged = (
served: Served,
refused: readonly (readonly [string, ReplayArguments, string])[],
): void => {
const before = served.snapshot();
for (const [name, args, message] of refused) {
expect(served.refusal(name, args)).toMatchObject({
code: "invalid_request",
message: expect.stringContaining(message),
});
expect(served.snapshot()).toBe(before);
}
};
/** What data/get reports loads back through data/add byte for byte. */
const expectReloadsUnchanged = (served: Served): void => {
const before = served.snapshot();
expect(served.store.replace(JSON.parse(before)).ok).toBe(true);
expect(served.snapshot()).toBe(before);
};
const nested = (depth: number): JsonValue =>
Array.from({ length: depth }).reduce<JsonValue>((inner) => [inner], 1);
describe("served document writes", () => {
it("accepts only the members the capture sent, as the JSON types the document holds", () => {
const settings = servedFrom(SETTINGS_ROWS);
expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({
served: true,
effect: "document",
});
expectRefusedUnchanged(settings, [
[
PATCH_SETTINGS,
{ id: "1", body: { theme: 5, extra: true } },
"Member theme must be a JSON string",
],
[
PATCH_SETTINGS,
{ id: "1", body: { extra: true } },
"Member extra is not one the capture showed stored",
],
]);
const blue = { theme: "blue", meta: DARK.meta };
expect(
settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "blue" } }),
).toMatchObject({
body: blue,
state: { view: "document-write", path: "/api/orgs/1/settings" },
});
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(blue);
});
it("leaves a document write unserved when its recording changed a member's JSON type, which serving would refuse", () => {
const retyped = { theme: 5, meta: LIGHT.meta };
const settings = servedFrom([
["GET", "/api/orgs/1/settings", DARK],
["PATCH", "/api/orgs/1/settings", retyped, 200, retyped],
["GET", "/api/orgs/1/settings", retyped],
]);
expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({
served: false,
reason: "unexplained-change",
});
});
it("changes the document only at the path the state holds it", () => {
const settings = servedFrom(SETTINGS_ROWS);
expectRefusedUnchanged(settings, [
[
PATCH_SETTINGS,
{ id: "2", body: { theme: "blue" } },
"holds this document only at /api/orgs/1/settings",
],
]);
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(DARK);
});
});
describe("values the state can load back", () => {
it("refuses a body value data/add would refuse, whatever member carries it", () => {
const settings = servedFrom(SETTINGS_ROWS);
const unexact =
"Member meta would hold a value that is not exactly representable JSON";
expectRefusedUnchanged(settings, [
[PATCH_SETTINGS, { id: "1", body: { meta: { n: 1e20 } } }, unexact],
[
PATCH_SETTINGS,
{
id: "1",
body: JSON.parse('{"meta":{"__proto__":{"n":1}}}') as JsonValue,
},
unexact,
],
[
PATCH_SETTINGS,
{ id: "1", body: { meta: { n: nested(MAX_JSON_DEPTH) } } },
unexact,
],
]);
settings.answer(PATCH_SETTINGS, { id: "1", body: { meta: { n: 3 } } });
expectReloadsUnchanged(settings);
});
it("refuses an increment past the exact integers, so data/get still loads back", () => {
const versioned = (entry: Book, version: number): Book => ({
...entry,
version,
});
const last = Number.MAX_SAFE_INTEGER;
const edition = versioned(renamedBook(SECOND_BOOK, "Second Edition", 3), 2);
const books = servedFrom([
[
"GET",
"/api/books",
[versioned(FIRST_BOOK, last - 1), versioned(SECOND_BOOK, 1)],
],
["GET", "/api/books/1", versioned(FIRST_BOOK, last - 1)],
["GET", "/api/books/2", versioned(SECOND_BOOK, 1)],
["PATCH", "/api/books/2", edition, 200, { name: "Second Edition" }],
["GET", "/api/books/2", edition],
]);
const rename = (name: string) => ({ id: "1", body: { name } });
expect(
books.answer("patch_books_by_id", rename("Renamed")).body,
).toMatchObject({ version: last });
expectRefusedUnchanged(books, [
[
"patch_books_by_id",
rename("Renamed Again"),
"Field version would hold a value that is not exactly representable JSON",
],
]);
expectReloadsUnchanged(books);
});
});
describe("the state size limit", () => {
it("keeps the size data/get writes as each write changes the state", () => {
const books = servedFrom();
const settings = servedFrom(SETTINGS_ROWS);
const tracked = (served: Served) =>
expect(served.store.byteLength()).toBe(
Buffer.byteLength(served.snapshot(), "utf8"),
);
tracked(books);
books.answer("post_books", { body: { name: "Ünïcode Bøok" } });
tracked(books);
books.answer("patch_books_by_id", { id: "3", body: { name: "Short" } });
tracked(books);
books.answer("delete_books_by_id", { id: "1" });
books.answer("delete_books_by_id", { id: "2" });
books.answer("delete_books_by_id", { id: "3" });
tracked(books);
books.answer("post_books", { body: { name: "Only Book" } });
tracked(books);
settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "dark blue" } });
tracked(settings);
expect(settings.store.replace(JSON.parse(settings.snapshot())).ok).toBe(
true,
);
tracked(settings);
});
it("refuses a create that would take data/get past what data/add loads", () => {
const books = servedFrom();
// Each answer renders the book, so each stays under the reply body limit.
const create = { body: { name: "a".repeat(100_000) } };
let refused: ReturnType<Served["call"]> | undefined;
for (let made = 0; made < 200 && refused === undefined; made += 1) {
const result = books.call("post_books", create);
if (!result.ok) refused = result;
}
const before = books.snapshot();
expect(Buffer.byteLength(before, "utf8")).toBeLessThanOrEqual(
STATE_INTAKE_LIMIT_BYTES,
);
expect(refused?.ok === false && refused.error).toMatchObject({
resource: "capacity",
reportedLimits: { max_state_bytes: STATE_INTAKE_LIMIT_BYTES },
});
expect(books.refusal("post_books", create)).toMatchObject({
code: "resource_constraint",
});
expect(books.snapshot()).toBe(before);
expectReloadsUnchanged(books);
// A smaller state has room again.
books.answer("delete_books_by_id", { id: "3" });
expect(books.answer("post_books", create).status).toBe(201);
});
});