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>
223 lines
7.9 KiB
TypeScript
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);
|
|
});
|
|
});
|