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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10950-a-refused-rest-call-tells-an-agent-what → main 6 commits fork 4 threads · 4 unresolved
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 | ||
| 544 | 544 | `flow` with `{ kind: "drift", repo, pull, action: "update" }` does the same as a writer of |
| 545 | 545 | the repository: `{ updating, by }`, or the route's refusals in its words (only a writer; |
| 546 | 546 | 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 { | ||
| 403 | 403 | return new Response( |
| 404 | 404 | JSON.stringify({ |
| 405 | 405 | error: "unauthorized", |
| 406 | hint: "Send `Authorization: Bearer <token>` with a token you hold, then repeat the call; without one, only the `report` tool answers.", | |
| 406 | 407 | message: |
| 407 | 408 | "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", |
| 408 | 409 | report: { |
src/http/respond.ts +33
| @@ -28,12 +28,45 @@ export function json( | ||
| 28 | 28 | if (typeof data === "object" && data !== null && !Array.isArray(data) && !("retryAfter" in data)) |
| 29 | 29 | data = { ...data, retryAfter: wait }; |
| 30 | 30 | } |
| 31 | data = withHint(data, status); | |
| 31 | 32 | return new Response(JSON.stringify(data), { |
| 32 | 33 | status, |
| 33 | 34 | headers: { "content-type": "application/json", ...headers }, |
| 34 | 35 | }); |
| 35 | 36 | } |
| 36 | 37 | |
| 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 | ||
| 37 | 70 | /** |
| 38 | 71 | * A 429 or 503 that says for how long: an integer `Retry-After` header and the |
| 39 | 72 | * 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", () => { | ||
| 627 | 627 | body: JSON.stringify({ name: "guarded" }), |
| 628 | 628 | }); |
| 629 | 629 | expect(res.status).toBe(403); |
| 630 | expect(await res.json()).toEqual({ | |
| 630 | expect(await res.json()).toMatchObject({ | |
| 631 | 631 | error: "refused", |
| 632 | 632 | message: "forge: new repositories are paused here", |
| 633 | 633 | }); |
| @@ -659,7 +659,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 659 | 659 | body: JSON.stringify({ name: "guarded-plain" }), |
| 660 | 660 | }); |
| 661 | 661 | expect(res.status).toBe(403); |
| 662 | expect(await res.json()).toEqual({ | |
| 662 | expect(await res.json()).toMatchObject({ | |
| 663 | 663 | error: "refused", |
| 664 | 664 | message: "refused", |
| 665 | 665 | }); |
test/admin-gate.test.ts +1 −1
| @@ -118,7 +118,7 @@ describe("the admin gate", () => { | ||
| 118 | 118 | const res = await api("GET", "/api/v1/admin/settings", u); |
| 119 | 119 | expect(res.status).toBe(403); |
| 120 | 120 | 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 }); | |
| 122 | 122 | } |
| 123 | 123 | }); |
| 124 | 124 |
test/admin-words.test.ts +3 −3
| @@ -63,7 +63,7 @@ describe("an admin's refusals, from every door", () => { | ||
| 63 | 63 | test("an admin suspending themselves: the API and MCP say the module's sentence", async () => { |
| 64 | 64 | const r = await api(admin, "POST", "/api/v1/admin/accounts/alice/suspend", {}); |
| 65 | 65 | 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 }); | |
| 67 | 67 | const m = await mcp(admin, "account_suspend", { username: "alice", suspended: true }); |
| 68 | 68 | expect(m.error).toBe(true); |
| 69 | 69 | expect(m.text).toBe(`not_allowed: ${ADMIN_WORDS.selfSuspend}`); |
| @@ -72,13 +72,13 @@ describe("an admin's refusals, from every door", () => { | ||
| 72 | 72 | test("a non-admin reading the scorecard: the API says the module's sentence", async () => { |
| 73 | 73 | const r = await api(person, "GET", "/api/v1/crew/scorecard"); |
| 74 | 74 | 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 }); | |
| 76 | 76 | }); |
| 77 | 77 | |
| 78 | 78 | test("a non-admin setting the front page: the API and MCP say the module's sentence", async () => { |
| 79 | 79 | const r = await api(person, "PUT", "/api/v1/front", { pinned: [], hidden: [] }); |
| 80 | 80 | 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 }); | |
| 82 | 82 | const m = await mcp(person, "front_page", { pinned: [], hidden: [] }); |
| 83 | 83 | expect(m.error).toBe(true); |
| 84 | 84 | 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", () => { | ||
| 95 | 95 | expect(none.status).toBe(422); |
| 96 | 96 | const taken = await patch(admin, other.id, { name: "BUG" }); |
| 97 | 97 | 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 }); | |
| 99 | 99 | const bad = await patch(admin, other.id, { color: "red" }); |
| 100 | 100 | 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 }); | |
| 102 | 102 | expect((await patch(admin, other.id, { name: 5 })).status).toBe(422); |
| 103 | 103 | }); |
| 104 | 104 | }); |
test/api-rollback.test.ts +1 −1
| @@ -116,7 +116,7 @@ describe("POST /api/v1/admin/releases/:sha/rollback", () => { | ||
| 116 | 116 | test("applying a release the live schema forbids is 409 with the page's words", async () => { |
| 117 | 117 | const res = await roll(BEHIND, admin, { dryRun: false }); |
| 118 | 118 | 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 }); | |
| 120 | 120 | }); |
| 121 | 121 | |
| 122 | 122 | 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 () => { | ||
| 65 | 65 | test("severing a repository that follows nothing answers the module's sentence at both doors", async () => { |
| 66 | 66 | const res = await api("POST", "/repos/ada/lib/following/sever"); |
| 67 | 67 | 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 }); | |
| 69 | 69 | const mcp = await mcpTool(servers.url, token, "sever", { repo: "ada/lib" }); |
| 70 | 70 | expect(mcp.error).toBe(`not_following: ${FAMILY_WORDS.not_following}`); |
| 71 | 71 | }); |
| @@ -73,7 +73,7 @@ test("severing a repository that follows nothing answers the module's sentence a | ||
| 73 | 73 | test("showing a trail on a repository never migrated answers the module's sentence at both doors", async () => { |
| 74 | 74 | const res = await api("PATCH", "/repos/ada/lib", { showProvenance: true }); |
| 75 | 75 | 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 }); | |
| 77 | 77 | const mcp = await mcpTool(servers.url, token, "trail", { repo: "ada/lib", shown: true, dryRun: false }); |
| 78 | 78 | expect(mcp.error).toBe(`no_provenance: ${FAMILY_WORDS.no_provenance}`); |
| 79 | 79 | }); |
test/forbidden-page.test.ts +2 −2
| @@ -98,7 +98,7 @@ describe("the 403 page", () => { | ||
| 98 | 98 | body: "username=kim&password=x", |
| 99 | 99 | }); |
| 100 | 100 | 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 }); | |
| 102 | 102 | }); |
| 103 | 103 | |
| 104 | 104 | 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", () => { | ||
| 112 | 112 | }); |
| 113 | 113 | const asJson = forbiddenPage(ctx("application/json"), "Sign in to do this."); |
| 114 | 114 | 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." }); | |
| 116 | 116 | const html = await forbiddenPage(ctx("text/html"), "<b>no</b>").text(); |
| 117 | 117 | expect(html).toContain("<b>no</b>"); |
| 118 | 118 | expect(html).not.toContain("<b>no</b>"); |
test/guard-new-accounts.test.ts +1 −1
| @@ -172,7 +172,7 @@ describe("guard_new_accounts", () => { | ||
| 172 | 172 | body: JSON.stringify({ name: "api-blocked" }), |
| 173 | 173 | }); |
| 174 | 174 | expect(res.status).toBe(403); |
| 175 | expect(await res.json()).toEqual({ | |
| 175 | expect(await res.json()).toMatchObject({ | |
| 176 | 176 | error: "refused", |
| 177 | 177 | message: NEW_ACCOUNT_GUARD_MESSAGE, |
| 178 | 178 | }); |
test/list-cursor-state.test.ts +3 −3
| @@ -67,7 +67,7 @@ describe("list cursors and states are validated", () => { | ||
| 67 | 67 | for (const bad of ["abc", "0", "-1", "1.5", "", "1e3", "%25%25"]) { |
| 68 | 68 | const res = await get(`${route}?cursor=${bad}`); |
| 69 | 69 | expect(res.status).toBe(422); |
| 70 | expect(await res.json()).toEqual({ | |
| 70 | expect(await res.json()).toMatchObject({ | |
| 71 | 71 | error: "invalid_cursor", |
| 72 | 72 | message: "cursor is the next_cursor of a previous page", |
| 73 | 73 | }); |
| @@ -79,7 +79,7 @@ describe("list cursors and states are validated", () => { | ||
| 79 | 79 | for (const bad of ["bogus", "Closed", "", "OPEN"]) { |
| 80 | 80 | const res = await get(`${route}?state=${bad}`); |
| 81 | 81 | 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" }); | |
| 83 | 83 | } |
| 84 | 84 | for (const ok of ["", "?state=open", "?state=closed", "?state=all"]) |
| 85 | 85 | expect((await get(`${route}${ok}`)).status).toBe(200); |
| @@ -91,7 +91,7 @@ describe("list cursors and states are validated", () => { | ||
| 91 | 91 | for (const bad of ["%25%25", "a.b", "", "a".repeat(65), "a%20b"]) { |
| 92 | 92 | const res = await get(`${route}?cursor=${bad}`); |
| 93 | 93 | expect(res.status).toBe(422); |
| 94 | expect(await res.json()).toEqual({ | |
| 94 | expect(await res.json()).toMatchObject({ | |
| 95 | 95 | error: "invalid_cursor", |
| 96 | 96 | message: "cursor is the next_cursor of a previous page", |
| 97 | 97 | }); |
test/list-pages.test.ts +1 −1
| @@ -146,7 +146,7 @@ describe("forks, labels and webhooks are pages", () => { | ||
| 146 | 146 | for (const bad of ["abc", "0", "-1", "1.5", "201", ""]) { |
| 147 | 147 | const res = await fetch(`${base}${path}?limit=${bad}`, { headers }); |
| 148 | 148 | expect(res.status).toBe(422); |
| 149 | expect(await res.json()).toEqual({ | |
| 149 | expect(await res.json()).toMatchObject({ | |
| 150 | 150 | error: "invalid_limit", |
| 151 | 151 | message: "limit is a whole number from 1 to 200", |
| 152 | 152 | }); |
test/member-others.test.ts +1 −1
| @@ -87,7 +87,7 @@ describe("a member and another account", () => { | ||
| 87 | 87 | const res = await call(path, bobToken, method, body); |
| 88 | 88 | const gated = path.startsWith("/api/v1/admin/"); |
| 89 | 89 | 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 }); | |
| 91 | 91 | } |
| 92 | 92 | expect(listSessions(app.db, carol.id).some((s) => s.id === sid)).toBe(true); |
| 93 | 93 | // 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)", () => { | ||
| 187 | 187 | setHold(app.db, pull.id, getUserByName(app.db, "alice")!.id, "wait for the release"); |
| 188 | 188 | const held = await slice(mine[0]!); |
| 189 | 189 | expect(held.status).toBe(409); |
| 190 | expect(await held.json()).toEqual({ error: "held" }); | |
| 190 | expect(await held.json()).toMatchObject({ error: "held" }); | |
| 191 | 191 | // The page's form says so in its flash, and lands nothing. |
| 192 | 192 | const form = async (upTo: string): Promise<string> => { |
| 193 | 193 | const posted = await fetch(`${base}/alice/app/pulls/${number}/slice`, { |
| @@ -209,7 +209,7 @@ describe("a fork's pull lands in slices (#9010)", () => { | ||
| 209 | 209 | app.db.query("UPDATE repo SET require_review = 1 WHERE id = ?1").run(repo.id); |
| 210 | 210 | const unreviewed = await slice(mine[0]!); |
| 211 | 211 | expect(unreviewed.status).toBe(409); |
| 212 | expect(await unreviewed.json()).toEqual({ error: "review_required" }); | |
| 212 | expect(await unreviewed.json()).toMatchObject({ error: "review_required" }); | |
| 213 | 213 | app.db.query("UPDATE repo SET require_review = 0 WHERE id = ?1").run(repo.id); |
| 214 | 214 | |
| 215 | 215 | 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)", () => { | ||
| 217 | 217 | finishRun(app.db, run.id, { state: "failed", summary: "red" }); |
| 218 | 218 | const red = await slice(mine[0]!); |
| 219 | 219 | expect(red.status).toBe(409); |
| 220 | expect(await red.json()).toEqual({ error: "checks_required" }); | |
| 220 | expect(await red.json()).toMatchObject({ error: "checks_required" }); | |
| 221 | 221 | |
| 222 | 222 | // A branch of the repository itself lands whole. |
| 223 | 223 | const a = join(work, "a"); |
| @@ -233,6 +233,6 @@ describe("a fork's pull lands in slices (#9010)", () => { | ||
| 233 | 233 | const ownNumber = ((await own.json()) as { number: number }).number; |
| 234 | 234 | const notFork = await call(`/api/v1/repos/alice/app/pulls/${ownNumber}/slices`, alice, { upTo: first }); |
| 235 | 235 | expect(notFork.status).toBe(422); |
| 236 | expect(await notFork.json()).toEqual({ error: "not_fork" }); | |
| 236 | expect(await notFork.json()).toMatchObject({ error: "not_fork" }); | |
| 237 | 237 | }); |
| 238 | 238 | }); |
test/reserved-names.test.ts +5 −5
| @@ -87,7 +87,7 @@ describe("reserved names", () => { | ||
| 87 | 87 | ] as const) { |
| 88 | 88 | const res = await signup(name); |
| 89 | 89 | expect(res.status).toBe(422); |
| 90 | expect(await res.json()).toEqual({ error }); | |
| 90 | expect(await res.json()).toMatchObject({ error }); | |
| 91 | 91 | // No person account was made: the name still answers only to the agent. |
| 92 | 92 | expect(getUserByName(app.db, name)?.kind ?? "none").not.toBe("user"); |
| 93 | 93 | } |
| @@ -101,7 +101,7 @@ describe("reserved names", () => { | ||
| 101 | 101 | ] as const) { |
| 102 | 102 | const res = await signup(name); |
| 103 | 103 | expect(res.status).toBe(422); |
| 104 | expect(await res.json()).toEqual({ error }); | |
| 104 | expect(await res.json()).toMatchObject({ error }); | |
| 105 | 105 | } |
| 106 | 106 | }); |
| 107 | 107 | |
| @@ -118,7 +118,7 @@ describe("reserved names", () => { | ||
| 118 | 118 | ]) { |
| 119 | 119 | const res = await signup(name); |
| 120 | 120 | expect(res.status).toBe(422); |
| 121 | expect(await res.json()).toEqual({ error: "username_reserved" }); | |
| 121 | expect(await res.json()).toMatchObject({ error: "username_reserved" }); | |
| 122 | 122 | } |
| 123 | 123 | }); |
| 124 | 124 | |
| @@ -257,7 +257,7 @@ describe("reserved names", () => { | ||
| 257 | 257 | for (const name of ["trevato-official", "scout-bot", "torque-crew"]) { |
| 258 | 258 | const res = await signup(name); |
| 259 | 259 | expect(res.status).toBe(422); |
| 260 | expect(await res.json()).toEqual({ error: "username_reserved" }); | |
| 260 | expect(await res.json()).toMatchObject({ error: "username_reserved" }); | |
| 261 | 261 | } |
| 262 | 262 | expect((await signup("scout-finch")).status).toBe(201); |
| 263 | 263 | }); |
| @@ -268,7 +268,7 @@ describe("reserved names", () => { | ||
| 268 | 268 | for (const name of ["trevato2", "trevato007", "trevatoo", "trevatoofficial", "realtrevato", "scoutbot", "botscout", "torquecrew2"]) { |
| 269 | 269 | const res = await signup(name); |
| 270 | 270 | 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" }); | |
| 272 | 272 | } |
| 273 | 273 | const ada = getUserByName(app.db, "ada")!; |
| 274 | 274 | for (const displayName of ["Trevato2", "ScoutBot", "trevatoo"]) { |
test/spend-words.test.ts +8 −8
| @@ -65,30 +65,30 @@ afterAll(async () => { | ||
| 65 | 65 | |
| 66 | 66 | test("a grant out of range answers the module's sentence from the API and MCP", async () => { |
| 67 | 67 | 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 } }); | |
| 69 | 69 | 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}` }); | |
| 71 | 71 | }); |
| 72 | 72 | |
| 73 | 73 | test("a role's allowance out of range answers the module's sentence from the API and MCP", async () => { |
| 74 | 74 | 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 } }); | |
| 76 | 76 | 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}` }); | |
| 78 | 78 | }); |
| 79 | 79 | |
| 80 | 80 | test("a ceiling out of range answers the module's sentence from the API and MCP", async () => { |
| 81 | 81 | 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 } }); | |
| 83 | 83 | 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}` }); | |
| 85 | 85 | }); |
| 86 | 86 | |
| 87 | 87 | test("an invite's days out of range answers the module's sentence from the API and MCP", async () => { |
| 88 | 88 | 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 } }); | |
| 90 | 90 | 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}` }); | |
| 92 | 92 | }); |
| 93 | 93 | |
| 94 | 94 | 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", () => { | ||
| 140 | 140 | body: "{}", |
| 141 | 141 | }); |
| 142 | 142 | 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"] }); | |
| 144 | 144 | expect(fresh(pull).mergedAt).toBeNull(); |
| 145 | 145 | }); |
| 146 | 146 |
test/verify-email.test.ts +1 −1
| @@ -122,7 +122,7 @@ describe("verified email", () => { | ||
| 122 | 122 | ]; |
| 123 | 123 | for (const res of await Promise.all(refusals)) { |
| 124 | 124 | expect(res.status).toBe(403); |
| 125 | expect(await res.json()).toEqual({ | |
| 125 | expect(await res.json()).toMatchObject({ | |
| 126 | 126 | error: "unverified", |
| 127 | 127 | message: "verify your email first", |
| 128 | 128 | }); |
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.