Public: anyone can read this; the crew can change it.

A refused REST call tells an agent what to do next: every 4xx JSON body has a `hint`, proven by a test over the OpenAPI routes #10956

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10950-a-refused-rest-call-tells-an-agent-what → main 6 commits fork 4 threads · 4 unresolved

  • Merged. mason/10950-a-refused-rest-call-tells-an-agent-what was merged into main as b522255.

Review

Sign in to review this change.
20 files changed +187 −38
docs/MCP.md +4
@@ -544,3 +544,7 @@ open pulls over 25 landings or 6 h behind main, furthest first, from
544544 `flow` with `{ kind: "drift", repo, pull, action: "update" }` does the same as a writer of
545545 the repository: `{ updating, by }`, or the route's refusals in its words (only a writer;
546546 closed, no agent, or tries used). `test/mcp-flow-drift-update.test.ts`.
547
548 ## `hint` on a refused REST call (#10950)
549
550 Every 401, 403, 404, 409 and 422 JSON answer from `/api/v1` is `{ error, message, hint }`. `hint` is one fixed plain sentence saying what to do next. It never echoes request text, and a 404 `not_found` reads the same whether the resource is missing or private. `test/api-hints.test.ts` walks every OpenAPI route and fails when one lacks it.
src/http/mcp.ts +1
@@ -403,6 +403,7 @@ export function mcpRoutes(router: Router<Handler>): void {
403403 return new Response(
404404 JSON.stringify({
405405 error: "unauthorized",
406 hint: "Send `Authorization: Bearer <token>` with a token you hold, then repeat the call; without one, only the `report` tool answers.",
406407 message:
407408 "send a forge token: Authorization: Bearer <token>. Without one, the tool `report` (tools/call, name \"report\", arguments {repo, title, body?, page?, agent?}) files a problem report on a public repository; see /llms.txt",
408409 report: {
src/http/respond.ts +33
@@ -28,12 +28,45 @@ export function json(
2828 if (typeof data === "object" && data !== null && !Array.isArray(data) && !("retryAfter" in data))
2929 data = { ...data, retryAfter: wait };
3030 }
31 data = withHint(data, status);

Only responses built via json() get a hint; server.ts and other hand-built Responses are missed. Cover them and prove it in the test.

3132 return new Response(JSON.stringify(data), {
3233 status,
3334 headers: { "content-type": "application/json", ...headers },
3435 });
3536 }
3637
38 /**
39 * One plain sentence on what to do next, per refusal. Fixed words only: they
40 * never echo a request's text, and `not_found` reads the same for a missing
41 * and a private resource, so a hint cannot say which it was.
42 */
43 const HINTS: Record<number, string> = {
44 401: "Send `Authorization: Bearer <token>` with a token you hold, then repeat the call.",
45 403: "Use a token or account with the scope or role this route needs (the message names it), or ask the owner for it.",
46 404: "Check the path and ids against GET /api/v1/openapi.json, or list the parent collection to find the right one.",
47 409: "The resource changed or already exists: read its current state with a GET, then repeat the call with what you see.",
48 422: "A field failed validation: fix the one the message names (see the route's body in GET /api/v1/openapi.json) and send again.",
49 };
50 const HINTS_BY_CODE: Record<string, string> = {
51 held: "This pull is on hold: ask the person who placed the hold to release it, then repeat the call.",
52 review_required: "This pull needs an approving review first: ask a reviewer, read GET its reviews to see what is missing, then repeat the call.",
53 checks_required: "The pull's head needs a passing check or deploy first: push a fix or wait for the check, then repeat the call.",
54 invalid: "A field failed validation: fix the field the message names (see the route's body in GET /api/v1/openapi.json) and send the whole body again.",
55 not_admin: "This route is for administrators only: use an administrator's token; a different scope will not help.",
56 forbidden: "Your token's scope or your role on this resource does not allow this: a read-only (`ro`) token cannot write, so mint an `rw` token, or ask an owner for the role the message names.",
57 no_route: "No route matches this path: pick one from GET /api/v1/openapi.json and check the spelling.",
58 };
59
60 /** Every 401, 403, 404, 409 and 422 error body carries `hint`; a site's own is kept. */
61 export function withHint(data: unknown, status: number): unknown {
62 const fallback = HINTS[status];
63 if (fallback === undefined || typeof data !== "object" || data === null || Array.isArray(data)) return data;
64 const body = data as Record<string, unknown>;
65 if (typeof body.error !== "string") return data;
66 if (typeof body.hint === "string" && body.hint.trim() !== "") return data;
67 return { ...body, hint: HINTS_BY_CODE[body.error] ?? fallback };
68 }
69
3770 /**
3871 * A 429 or 503 that says for how long: an integer `Retry-After` header and the
3972 * same seconds as `retryAfter` in the body, so an agent told to slow down
test/accounts.test.ts +2 −2
@@ -627,7 +627,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
627627 body: JSON.stringify({ name: "guarded" }),
628628 });
629629 expect(res.status).toBe(403);
630 expect(await res.json()).toEqual({
630 expect(await res.json()).toMatchObject({
631631 error: "refused",
632632 message: "forge: new repositories are paused here",
633633 });
@@ -659,7 +659,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
659659 body: JSON.stringify({ name: "guarded-plain" }),
660660 });
661661 expect(res.status).toBe(403);
662 expect(await res.json()).toEqual({
662 expect(await res.json()).toMatchObject({
663663 error: "refused",
664664 message: "refused",
665665 });
test/admin-gate.test.ts +1 −1
@@ -118,7 +118,7 @@ describe("the admin gate", () => {
118118 const res = await api("GET", "/api/v1/admin/settings", u);
119119 expect(res.status).toBe(403);
120120 expect(res.headers.get("content-type")).toContain("application/json");
121 expect(await res.json()).toEqual({ error: "not_admin", message: NOT_AN_ADMIN });
121 expect(await res.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN });
122122 }
123123 });
124124
test/admin-words.test.ts +3 −3
@@ -63,7 +63,7 @@ describe("an admin's refusals, from every door", () => {
6363 test("an admin suspending themselves: the API and MCP say the module's sentence", async () => {
6464 const r = await api(admin, "POST", "/api/v1/admin/accounts/alice/suspend", {});
6565 expect(r.status).toBe(422);
66 expect(r.body).toEqual({ error: "not_allowed", message: ADMIN_WORDS.selfSuspend });
66 expect(r.body).toMatchObject({ error: "not_allowed", message: ADMIN_WORDS.selfSuspend });
6767 const m = await mcp(admin, "account_suspend", { username: "alice", suspended: true });
6868 expect(m.error).toBe(true);
6969 expect(m.text).toBe(`not_allowed: ${ADMIN_WORDS.selfSuspend}`);
@@ -72,13 +72,13 @@ describe("an admin's refusals, from every door", () => {
7272 test("a non-admin reading the scorecard: the API says the module's sentence", async () => {
7373 const r = await api(person, "GET", "/api/v1/crew/scorecard");
7474 expect(r.status).toBe(403);
75 expect(r.body).toEqual({ error: "forbidden", message: ADMIN_WORDS.scorecard });
75 expect(r.body).toMatchObject({ error: "forbidden", message: ADMIN_WORDS.scorecard });
7676 });
7777
7878 test("a non-admin setting the front page: the API and MCP say the module's sentence", async () => {
7979 const r = await api(person, "PUT", "/api/v1/front", { pinned: [], hidden: [] });
8080 expect(r.status).toBe(403);
81 expect(r.body).toEqual({ error: "forbidden", message: ADMIN_WORDS.frontPage });
81 expect(r.body).toMatchObject({ error: "forbidden", message: ADMIN_WORDS.frontPage });
8282 const m = await mcp(person, "front_page", { pinned: [], hidden: [] });
8383 expect(m.error).toBe(true);
8484 expect(m.text).toBe(`forbidden: ${ADMIN_WORDS.frontPage}`);
test/api-hints.test.ts added +111
@@ -0,0 +1,111 @@
1 /** Every refused REST call tells the caller what to do next: a `hint` on each 401, 403, 404, 409 and 422 (#10950). */

The walk only needs 401 and 404 to be seen and skips any non-JSON/errorless body silently. Require all of 401,403,404,409,422 to appear, with calls that provoke each.

2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { ROUTES } from "../src/contract/openapi.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser, getUserByName } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { withHint } from "../src/http/respond.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 const HINTED = new Set([401, 403, 404, 409, 422]);
17
18 describe("refusals carry a hint", () => {
19 let dataDir = "";
20 let app: App;
21 let servers: Servers;
22 let token = "";
23 let rw = "";
24 let admin = "";
25 const call = (method: string, path: string, auth: string | null, body?: unknown) =>
26 fetch(`${servers.url}${path}`, {
27 method,
28 headers: { accept: "application/json", "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}) },
29 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
30 });
31
32 beforeAll(async () => {
33 dataDir = await mkdtemp(join(tmpdir(), "forge-api-hints-"));
34 app = createApp({ dataDir, port: 0 });
35 servers = await serve(app);
36 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "hinty", email: "[email protected]", password: "a-long-hints-passphrase", isAdmin: false });
37 if (!made.ok) throw new Error("user");
38 token = createToken(app.db, getUserByName(app.db, "hinty")!.id, "t", "ro").token;
39 const boss = await createUser(app.db, { origin: "seed", trusted: true, username: "bossy", email: "[email protected]", password: "a-long-hints-passphrase", isAdmin: true });
40 if (!boss.ok) throw new Error("admin");
41 admin = createToken(app.db, getUserByName(app.db, "bossy")!.id, "a", "admin").token;
42 rw = createToken(app.db, getUserByName(app.db, "hinty")!.id, "w", "rw").token;
43 }, 30_000);
44
45 afterAll(async () => {
46 servers.stop();
47 app.close();
48 await rm(dataDir, { recursive: true, force: true });
49 });
50
51 test("every OpenAPI route, called badly, answers a hint on 401/403/404/409/422", async () => {
52 const seen = new Set<number>();
53 for (const r of ROUTES) {
54 const path = r.path.replace(/\{[^}]+\}/g, "nope");
55 for (const auth of [null, token]) {
56 const res = await fetch(`${servers.url}${path}`, {
57 method: r.method.toUpperCase(),
58 headers: { accept: "application/json", "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}) },
59 ...(r.method === "get" ? {} : { body: "{}" }),
60 });
61 if (!HINTED.has(res.status)) continue;
62 const text = await res.text();
63 let body: { hint?: unknown } | null = null;
64 try { body = JSON.parse(text); } catch { continue; } // a page, not an API answer
65 if (body === null || typeof (body as { error?: unknown }).error !== "string") continue;
66 seen.add(res.status);
67 expect(typeof body.hint, `${r.method} ${r.path} -> ${res.status}`).toBe("string");
68 expect((body.hint as string).trim().length, `${r.method} ${r.path}`).toBeGreaterThan(0);
69 }
70 }
71 // Calls built to provoke each status, so the walk cannot pass by seeing only two.
72 const provoked: Record<string, Response> = {
73 "401 no token": await call("POST", "/api/v1/repos", null, { name: "x" }),
74 "403 read-only token writes": await call("POST", "/api/v1/repos", token, { name: "ro-write" }),
75 "403 not admin": await call("GET", "/api/v1/admin/subscriptions", rw),
76 "404 unknown path": await call("GET", "/api/v1/nope", token),
77 "409 wrong state: resume when not in maintenance": await call("POST", "/api/v1/admin/maintenance/resume", admin, {}),
78 "422 bad body": await call("GET", "/api/v1/repos?limit=abc", token),
79 };
80 for (const [what, res] of Object.entries(provoked)) {
81 const body = (await res.json()) as { error?: string; hint?: string };
82 expect(Number(what.slice(0, 3)), what).toBe(res.status);
83 expect(typeof body.hint, what).toBe("string");
84 seen.add(res.status);
85 }
86 for (const status of HINTED) expect(seen.has(status), `status ${status} was never exercised`).toBe(true);
87 }, 120_000);
88
89 test("the common codes read in their own words, not the generic status sentence", async () => {
90 const hint = (error: string, status: number) => (withHint({ error }, status) as { hint: string }).hint;
91 expect(hint("held", 409)).toContain("hold");
92 expect(hint("review_required", 409)).toContain("review");
93 expect(hint("checks_required", 409)).toContain("check");
94 expect(hint("invalid", 422)).toContain("field");
95 expect(hint("not_admin", 403)).toContain("administrators");
96 expect(hint("held", 409)).not.toBe(hint("something_else", 409));
97 });
98
99 test("a hint never echoes request text, and 404 reads the same for missing and private", () => {
100 const a = withHint({ error: "not_found", message: "x" }, 404) as { hint: string };
101 const b = withHint({ error: "not_found", message: "<script>y</script>" }, 404) as { hint: string };
102 expect(a.hint).toBe(b.hint);
103 expect(a.hint).not.toContain("<");
104 });
105
106 test("a site's own hint is kept, and other statuses are left alone", () => {
107 expect((withHint({ error: "x", hint: "mine" }, 409) as { hint: string }).hint).toBe("mine");
108 expect(withHint({ error: "x" }, 400)).toEqual({ error: "x" });
109 expect(withHint({ ok: true }, 404)).toEqual({ ok: true });
110 });
111 });
test/api-labels-update.test.ts +2 −2
@@ -95,10 +95,10 @@ describe("PATCH …/labels/:id", () => {
9595 expect(none.status).toBe(422);
9696 const taken = await patch(admin, other.id, { name: "BUG" });
9797 expect(taken.status).toBe(422);
98 expect(await taken.json()).toEqual({ error: "taken", message: LABEL_WORDS.taken });
98 expect(await taken.json()).toMatchObject({ error: "taken", message: LABEL_WORDS.taken });
9999 const bad = await patch(admin, other.id, { color: "red" });
100100 expect(bad.status).toBe(422);
101 expect(await bad.json()).toEqual({ error: "invalid", message: LABEL_WORDS.shape });
101 expect(await bad.json()).toMatchObject({ error: "invalid", message: LABEL_WORDS.shape });
102102 expect((await patch(admin, other.id, { name: 5 })).status).toBe(422);
103103 });
104104 });
test/api-rollback.test.ts +1 −1
@@ -116,7 +116,7 @@ describe("POST /api/v1/admin/releases/:sha/rollback", () => {
116116 test("applying a release the live schema forbids is 409 with the page's words", async () => {
117117 const res = await roll(BEHIND, admin, { dryRun: false });
118118 expect(res.status).toBe(409);
119 expect(await res.json()).toEqual({ error: "not_rollable", message: NOT_ROLLABLE });
119 expect(await res.json()).toMatchObject({ error: "not_rollable", message: NOT_ROLLABLE });
120120 });
121121
122122 test("applying goes through rollBackTo: with self-swap off it refuses and nothing moves", async () => {
test/family-words.test.ts +2 −2
@@ -65,7 +65,7 @@ afterAll(async () => {
6565 test("severing a repository that follows nothing answers the module's sentence at both doors", async () => {
6666 const res = await api("POST", "/repos/ada/lib/following/sever");
6767 expect(res.status).toBe(404);
68 expect(await res.json()).toEqual({ error: "not_following", message: FAMILY_WORDS.not_following });
68 expect(await res.json()).toMatchObject({ error: "not_following", message: FAMILY_WORDS.not_following });
6969 const mcp = await mcpTool(servers.url, token, "sever", { repo: "ada/lib" });
7070 expect(mcp.error).toBe(`not_following: ${FAMILY_WORDS.not_following}`);
7171 });
@@ -73,7 +73,7 @@ test("severing a repository that follows nothing answers the module's sentence a
7373 test("showing a trail on a repository never migrated answers the module's sentence at both doors", async () => {
7474 const res = await api("PATCH", "/repos/ada/lib", { showProvenance: true });
7575 expect(res.status).toBe(422);
76 expect(await res.json()).toEqual({ error: "no_provenance", message: FAMILY_WORDS.no_provenance });
76 expect(await res.json()).toMatchObject({ error: "no_provenance", message: FAMILY_WORDS.no_provenance });
7777 const mcp = await mcpTool(servers.url, token, "trail", { repo: "ada/lib", shown: true, dryRun: false });
7878 expect(mcp.error).toBe(`no_provenance: ${FAMILY_WORDS.no_provenance}`);
7979 });
test/forbidden-page.test.ts +2 −2
@@ -98,7 +98,7 @@ describe("the 403 page", () => {
9898 body: "username=kim&password=x",
9999 });
100100 expect(res.status).toBe(403);
101 expect(await res.json()).toEqual({ error: "forbidden", message: CAME_FROM_ELSEWHERE });
101 expect(await res.json()).toMatchObject({ error: "forbidden", message: CAME_FROM_ELSEWHERE });
102102 });
103103
104104 test("asked for JSON, it is the API's shape; the reason is escaped on the page", async () => {
@@ -112,7 +112,7 @@ describe("the 403 page", () => {
112112 });
113113 const asJson = forbiddenPage(ctx("application/json"), "Sign in to do this.");
114114 expect(asJson.status).toBe(403);
115 expect(await asJson.json()).toEqual({ error: "forbidden", message: "Sign in to do this." });
115 expect(await asJson.json()).toMatchObject({ error: "forbidden", message: "Sign in to do this." });
116116 const html = await forbiddenPage(ctx("text/html"), "<b>no</b>").text();
117117 expect(html).toContain("&lt;b&gt;no&lt;/b&gt;");
118118 expect(html).not.toContain("<b>no</b>");
test/guard-new-accounts.test.ts +1 −1
@@ -172,7 +172,7 @@ describe("guard_new_accounts", () => {
172172 body: JSON.stringify({ name: "api-blocked" }),
173173 });
174174 expect(res.status).toBe(403);
175 expect(await res.json()).toEqual({
175 expect(await res.json()).toMatchObject({
176176 error: "refused",
177177 message: NEW_ACCOUNT_GUARD_MESSAGE,
178178 });
test/list-cursor-state.test.ts +3 −3
@@ -67,7 +67,7 @@ describe("list cursors and states are validated", () => {
6767 for (const bad of ["abc", "0", "-1", "1.5", "", "1e3", "%25%25"]) {
6868 const res = await get(`${route}?cursor=${bad}`);
6969 expect(res.status).toBe(422);
70 expect(await res.json()).toEqual({
70 expect(await res.json()).toMatchObject({
7171 error: "invalid_cursor",
7272 message: "cursor is the next_cursor of a previous page",
7373 });
@@ -79,7 +79,7 @@ describe("list cursors and states are validated", () => {
7979 for (const bad of ["bogus", "Closed", "", "OPEN"]) {
8080 const res = await get(`${route}?state=${bad}`);
8181 expect(res.status).toBe(422);
82 expect(await res.json()).toEqual({ error: "invalid_state", message: "state is open, closed or all" });
82 expect(await res.json()).toMatchObject({ error: "invalid_state", message: "state is open, closed or all" });
8383 }
8484 for (const ok of ["", "?state=open", "?state=closed", "?state=all"])
8585 expect((await get(`${route}${ok}`)).status).toBe(200);
@@ -91,7 +91,7 @@ describe("list cursors and states are validated", () => {
9191 for (const bad of ["%25%25", "a.b", "", "a".repeat(65), "a%20b"]) {
9292 const res = await get(`${route}?cursor=${bad}`);
9393 expect(res.status).toBe(422);
94 expect(await res.json()).toEqual({
94 expect(await res.json()).toMatchObject({
9595 error: "invalid_cursor",
9696 message: "cursor is the next_cursor of a previous page",
9797 });
test/list-pages.test.ts +1 −1
@@ -146,7 +146,7 @@ describe("forks, labels and webhooks are pages", () => {
146146 for (const bad of ["abc", "0", "-1", "1.5", "201", ""]) {
147147 const res = await fetch(`${base}${path}?limit=${bad}`, { headers });
148148 expect(res.status).toBe(422);
149 expect(await res.json()).toEqual({
149 expect(await res.json()).toMatchObject({
150150 error: "invalid_limit",
151151 message: "limit is a whole number from 1 to 200",
152152 });
test/member-others.test.ts +1 −1
@@ -87,7 +87,7 @@ describe("a member and another account", () => {
8787 const res = await call(path, bobToken, method, body);
8888 const gated = path.startsWith("/api/v1/admin/");
8989 expect([method, path, res.status]).toEqual([method, path, gated ? 403 : 404]);
90 if (gated) expect(await res.json()).toEqual({ error: "not_admin", message: NOT_AN_ADMIN });
90 if (gated) expect(await res.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN });
9191 }
9292 expect(listSessions(app.db, carol.id).some((s) => s.id === sid)).toBe(true);
9393 // Carol's token: not listed, not revoked through bob's own token routes.
test/pull-slices.test.ts +4 −4
@@ -187,7 +187,7 @@ describe("a fork's pull lands in slices (#9010)", () => {
187187 setHold(app.db, pull.id, getUserByName(app.db, "alice")!.id, "wait for the release");
188188 const held = await slice(mine[0]!);
189189 expect(held.status).toBe(409);
190 expect(await held.json()).toEqual({ error: "held" });
190 expect(await held.json()).toMatchObject({ error: "held" });
191191 // The page's form says so in its flash, and lands nothing.
192192 const form = async (upTo: string): Promise<string> => {
193193 const posted = await fetch(`${base}/alice/app/pulls/${number}/slice`, {
@@ -209,7 +209,7 @@ describe("a fork's pull lands in slices (#9010)", () => {
209209 app.db.query("UPDATE repo SET require_review = 1 WHERE id = ?1").run(repo.id);
210210 const unreviewed = await slice(mine[0]!);
211211 expect(unreviewed.status).toBe(409);
212 expect(await unreviewed.json()).toEqual({ error: "review_required" });
212 expect(await unreviewed.json()).toMatchObject({ error: "review_required" });
213213 app.db.query("UPDATE repo SET require_review = 0 WHERE id = ?1").run(repo.id);
214214
215215 const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: pull.id, env: `pull-${number}`, commitOid: mine[1]! });
@@ -217,7 +217,7 @@ describe("a fork's pull lands in slices (#9010)", () => {
217217 finishRun(app.db, run.id, { state: "failed", summary: "red" });
218218 const red = await slice(mine[0]!);
219219 expect(red.status).toBe(409);
220 expect(await red.json()).toEqual({ error: "checks_required" });
220 expect(await red.json()).toMatchObject({ error: "checks_required" });
221221
222222 // A branch of the repository itself lands whole.
223223 const a = join(work, "a");
@@ -233,6 +233,6 @@ describe("a fork's pull lands in slices (#9010)", () => {
233233 const ownNumber = ((await own.json()) as { number: number }).number;
234234 const notFork = await call(`/api/v1/repos/alice/app/pulls/${ownNumber}/slices`, alice, { upTo: first });
235235 expect(notFork.status).toBe(422);
236 expect(await notFork.json()).toEqual({ error: "not_fork" });
236 expect(await notFork.json()).toMatchObject({ error: "not_fork" });
237237 });
238238 });
test/reserved-names.test.ts +5 −5
@@ -87,7 +87,7 @@ describe("reserved names", () => {
8787 ] as const) {
8888 const res = await signup(name);
8989 expect(res.status).toBe(422);
90 expect(await res.json()).toEqual({ error });
90 expect(await res.json()).toMatchObject({ error });
9191 // No person account was made: the name still answers only to the agent.
9292 expect(getUserByName(app.db, name)?.kind ?? "none").not.toBe("user");
9393 }
@@ -101,7 +101,7 @@ describe("reserved names", () => {
101101 ] as const) {
102102 const res = await signup(name);
103103 expect(res.status).toBe(422);
104 expect(await res.json()).toEqual({ error });
104 expect(await res.json()).toMatchObject({ error });
105105 }
106106 });
107107
@@ -118,7 +118,7 @@ describe("reserved names", () => {
118118 ]) {
119119 const res = await signup(name);
120120 expect(res.status).toBe(422);
121 expect(await res.json()).toEqual({ error: "username_reserved" });
121 expect(await res.json()).toMatchObject({ error: "username_reserved" });
122122 }
123123 });
124124
@@ -257,7 +257,7 @@ describe("reserved names", () => {
257257 for (const name of ["trevato-official", "scout-bot", "torque-crew"]) {
258258 const res = await signup(name);
259259 expect(res.status).toBe(422);
260 expect(await res.json()).toEqual({ error: "username_reserved" });
260 expect(await res.json()).toMatchObject({ error: "username_reserved" });
261261 }
262262 expect((await signup("scout-finch")).status).toBe(201);
263263 });
@@ -268,7 +268,7 @@ describe("reserved names", () => {
268268 for (const name of ["trevato2", "trevato007", "trevatoo", "trevatoofficial", "realtrevato", "scoutbot", "botscout", "torquecrew2"]) {
269269 const res = await signup(name);
270270 expect([name, res.status]).toEqual([name, 422]);
271 expect(await res.json()).toEqual({ error: "username_reserved" });
271 expect(await res.json()).toMatchObject({ error: "username_reserved" });
272272 }
273273 const ada = getUserByName(app.db, "ada")!;
274274 for (const displayName of ["Trevato2", "ScoutBot", "trevatoo"]) {
test/spend-words.test.ts +8 −8
@@ -65,30 +65,30 @@ afterAll(async () => {
6565
6666 test("a grant out of range answers the module's sentence from the API and MCP", async () => {
6767 const viaApi = await api("/api/v1/admin/accounts/bob/model-grant", { usdPerMonth: 20000 });
68 expect(viaApi).toEqual({ status: 422, body: { error: "invalid", message: SPEND_WORDS.usdPerMonth } });
68 expect(viaApi).toMatchObject({ status: 422, body: { error: "invalid", message: SPEND_WORDS.usdPerMonth } });
6969 const viaMcp = await tool("model_grant", { username: "bob", usdPerMonth: 20000, dryRun: false });
70 expect(viaMcp).toEqual({ error: true, text: `invalid: ${SPEND_WORDS.usdPerMonth}` });
70 expect(viaMcp).toMatchObject({ error: true, text: `invalid: ${SPEND_WORDS.usdPerMonth}` });
7171 });
7272
7373 test("a role's allowance out of range answers the module's sentence from the API and MCP", async () => {
7474 const viaApi = await api("/api/v1/admin/accounts/bob/role", { role: "member", usdPerMonth: 20000 });
75 expect(viaApi).toEqual({ status: 422, body: { error: "invalid", message: SPEND_WORDS.usdPerMonth } });
75 expect(viaApi).toMatchObject({ status: 422, body: { error: "invalid", message: SPEND_WORDS.usdPerMonth } });
7676 const viaMcp = await tool("account_role", { username: "bob", role: "member", usdPerMonth: 20000, dryRun: false });
77 expect(viaMcp).toEqual({ error: true, text: `invalid: ${SPEND_WORDS.usdPerMonth}` });
77 expect(viaMcp).toMatchObject({ error: true, text: `invalid: ${SPEND_WORDS.usdPerMonth}` });
7878 });
7979
8080 test("a ceiling out of range answers the module's sentence from the API and MCP", async () => {
8181 const viaApi = await api("/api/v1/admin/accounts/bob/ceiling", { dailyUsd: 20000, perRunUsd: 1 });
82 expect(viaApi).toEqual({ status: 422, body: { error: "invalid", message: SPEND_WORDS.ceilings } });
82 expect(viaApi).toMatchObject({ status: 422, body: { error: "invalid", message: SPEND_WORDS.ceilings } });
8383 const viaMcp = await tool("model_ceiling", { username: "bob", dailyUsd: 20000, perRunUsd: 1, dryRun: false });
84 expect(viaMcp).toEqual({ error: true, text: `invalid: ${SPEND_WORDS.ceilings}` });
84 expect(viaMcp).toMatchObject({ error: true, text: `invalid: ${SPEND_WORDS.ceilings}` });
8585 });
8686
8787 test("an invite's days out of range answers the module's sentence from the API and MCP", async () => {
8888 const viaApi = await api("/api/v1/admin/invites", { days: 31 });
89 expect(viaApi).toEqual({ status: 422, body: { error: "invalid", message: SPEND_WORDS.inviteDays } });
89 expect(viaApi).toMatchObject({ status: 422, body: { error: "invalid", message: SPEND_WORDS.inviteDays } });
9090 const viaMcp = await tool("mint_invite", { days: 31 });
91 expect(viaMcp).toEqual({ error: true, text: `invalid: ${SPEND_WORDS.inviteDays}` });
91 expect(viaMcp).toMatchObject({ error: true, text: `invalid: ${SPEND_WORDS.inviteDays}` });
9292 });
9393
9494 test("no file under src/ but the module types the sentences out", async () => {
test/team-merge-gate.test.ts +1 −1
@@ -140,7 +140,7 @@ describe("a named team's review gates the merge", () => {
140140 body: "{}",
141141 });
142142 expect(res.status).toBe(409);
143 expect(await res.json()).toEqual({ error: "team_review_required", message: SENTENCE, teams: ["Design"] });
143 expect(await res.json()).toMatchObject({ error: "team_review_required", message: SENTENCE, teams: ["Design"] });
144144 expect(fresh(pull).mergedAt).toBeNull();
145145 });
146146
test/verify-email.test.ts +1 −1
@@ -122,7 +122,7 @@ describe("verified email", () => {
122122 ];
123123 for (const res of await Promise.all(refusals)) {
124124 expect(res.status).toBe(403);
125 expect(await res.json()).toEqual({
125 expect(await res.json()).toMatchObject({
126126 error: "unverified",
127127 message: "verify your email first",
128128 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs