Public: anyone can read this; the crew can change it.
The OpenAPI description shows an anonymous reader only the routes it may call; admin routes appear only to an admin #11290
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11286-the-openapi-description-shows-an-anonymo → main 3 commits fork 1 thread · 1 unresolved
46 files
changed +337
−128
docs/MCP.md +3 −1
| @@ -548,7 +548,9 @@ answers | ||
| 548 | 548 | budget is held by a bounded statement count; the `here` phase is read from |
| 549 | 549 | Server-Timing and bounded at the flake floor (`WALL_CLOCK_FLOOR_MS`). |
| 550 | 550 | - **Contract.** `GET /contract/openapi.json` (also `/openapi.json`, `/api/v1/openapi.json`; `servers` is the request's origin, bearer `token` is the default `security`, open routes say `security: []`) is OpenAPI 3.1 for the routes |
| 551 | the manifest (`here`) names, plus `POST /mcp`. It also describes planning: `GET …/map`, `POST …/plan`, | |
| 551 | the manifest (`here`) names, plus `POST /mcp`, cut to the caller's role: no credential sees only `x-role: anonymous` | |
| 552 | rows, a member's token or session adds `member`, a site admin sees `admin` too; each role gets its own ETag, and any | |
| 553 | answer but the anonymous one is `cache-control: private` (`vary: authorization, cookie`). It also describes planning: `GET …/map`, `POST …/plan`, | |
| 552 | 554 | `POST …/issues/{number}/dependencies` and |
| 553 | 555 | `DELETE …/issues/{number}/dependencies/{blocker}`. `test/openapi.test.ts` fails |
| 554 | 556 | when the manifest names a route it lacks. |
src/contract/openapi.ts +20 −5
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | * so pulls describing different routes do not collide (#5936). |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import type { QueryType, Route } from "./openapi/shared.ts"; | |
| 14 | import type { QueryType, Role, Route } from "./openapi/shared.ts"; | |
| 15 | 15 | import { OAUTH_SCOPES, type OAuthScope } from "../core/oauth.ts"; |
| 16 | 16 | import { tagList, tagOf } from "./tags.ts"; |
| 17 | 17 | import * as each from "./openapi/routes/index.ts"; |
| @@ -47,10 +47,25 @@ const schema = (t: QueryType) => | ||
| 47 | 47 | ? { type: t.type, ...(t.min === undefined ? {} : { minimum: t.min }), ...(t.max === undefined ? {} : { maximum: t.max }) } |
| 48 | 48 | : (t === "object" ? { type: "object" } : t === "array" ? { type: "array", items: { type: "object" } } : { type: t }); |
| 49 | 49 | |
| 50 | /** The document, for the forge served at `origin`. */ | |
| 51 | export function openapi(origin: string, version: string): Record<string, unknown> { | |
| 50 | /** The roles each role may act as: an admin is a member, a member is anonymous too. */ | |
| 51 | const REACHES: Readonly<Record<Role, readonly Role[]>> = { | |
| 52 | anonymous: ["anonymous"], | |
| 53 | member: ["anonymous", "member"], | |
| 54 | admin: ["anonymous", "member", "admin"], | |
| 55 | }; | |
| 56 | ||
| 57 | /** The rows a caller of `role` may call: the document is cut to them (#11286). */ | |
| 58 | export const routesFor = (role: Role): readonly Route[] => ROUTES.filter((r) => REACHES[role].includes(r.role)); | |
| 59 | ||
| 60 | /** | |
| 61 | * The document, for the forge served at `origin`, cut to the rows `role` | |
| 62 | * may call. There is no default: `admin` is every row, and a caller that | |
| 63 | * wants it says so. | |
| 64 | */ | |
| 65 | export function openapi(origin: string, version: string, role: Role): Record<string, unknown> { | |
| 52 | 66 | const paths: Record<string, Record<string, unknown>> = {}; |
| 53 | for (const r of ROUTES) { | |
| 67 | const rows = routesFor(role); | |
| 68 | for (const r of rows) { | |
| 54 | 69 | const refusals = pagesByCursor(r) ? { invalid_query: PAGE_REFUSAL, ...r.refusals } : r.refusals; |
| 55 | 70 | const params = [...r.path.matchAll(/\{(\w+)\}/g)].map((m) => ({ |
| 56 | 71 | name: m[1], |
| @@ -133,7 +148,7 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 133 | 148 | "The routes the forge names to an agent. `not_found` means missing or not yours to see. Every 429 and 503 carries an integer `Retry-After` and `retryAfter` (the same seconds) in a JSON body.", |
| 134 | 149 | }, |
| 135 | 150 | servers: [{ url: origin }], |
| 136 | tags: tagList(ROUTES.map((r) => tagOf(r.path))), | |
| 151 | tags: tagList(rows.map((r) => tagOf(r.path))), | |
| 137 | 152 | // The default: a bearer token. Open routes say `security: []`. |
| 138 | 153 | security: [{ token: [] }], |
| 139 | 154 | paths, |
src/contract/served.ts +35 −18
| @@ -1,33 +1,44 @@ | ||
| 1 | 1 | /** |
| 2 | * The OpenAPI document as `/contract/openapi.json` serves it: built once per | |
| 3 | * origin and version, tagged by its own bytes, answered 304 to that tag. | |
| 2 | * The OpenAPI document as `/contract/openapi.json` serves it: cut to the | |
| 3 | * caller's role (anonymous, member, admin), built once per origin, version | |
| 4 | * and role, tagged by its own bytes, answered 304 to that tag. | |
| 4 | 5 | */ |
| 5 | 6 | |
| 6 | 7 | import { encodeOnce, negotiate } from "../http/compress.ts"; |
| 7 | 8 | import { holds, weakEtag } from "../http/etag.ts"; |
| 8 | import type { Handler } from "../http/context.ts"; | |
| 9 | import type { Auth, Handler } from "../http/context.ts"; | |
| 9 | 10 | import type { Router } from "../http/router.ts"; |
| 10 | 11 | import { running } from "../kernel/version.ts"; |
| 11 | 12 | import { openapi, ROUTES } from "./openapi.ts"; |
| 13 | import type { Role } from "./openapi/shared.ts"; | |
| 12 | 14 | import { tagList, tagOf } from "./tags.ts"; |
| 13 | 15 | |
| 14 | 16 | const MAX = 8; |
| 15 | 17 | /** The tags the document lists. */ |
| 16 | 18 | export const TAGS: readonly string[] = tagList(ROUTES.map((r) => tagOf(r.path))).map((t) => t.name); |
| 19 | const ROLES = 3; | |
| 17 | 20 | const built = new Map<string, { text: string; etag: string }>(); |
| 18 | 21 | |
| 22 | /** The role the asker holds: no account is anonymous, a site admin is admin, any other account a member. */ | |
| 23 | export function roleOf(auth: Pick<Auth, "user"> | undefined): Role { | |
| 24 | const user = auth?.user; | |
| 25 | return user === undefined ? "anonymous" : user.isAdmin ? "admin" : "member"; | |
| 26 | } | |
| 27 | ||
| 19 | 28 | /** |
| 20 | * The document and its tag. The origin comes from the request, so the map | |
| 21 | * is bounded; the oldest entry goes first. The tag hashes the text, so a | |
| 22 | * build without a version stamp still changes it when the contract does. | |
| 29 | * The document and its tag, for `role`: every caller names the role it | |
| 30 | * serves, so nothing reads the whole document by default. The origin comes from the request, | |
| 31 | * so the map is bounded; the oldest entry goes first. The tag hashes the | |
| 32 | * text, so it differs per role, and a build without a version stamp still | |
| 33 | * changes it when the contract does. | |
| 23 | 34 | */ |
| 24 | export function openapiText(origin: string, version: string): { text: string; etag: string } { | |
| 25 | const key = `${version}\n${origin}`; | |
| 35 | export function openapiText(origin: string, version: string, role: Role): { text: string; etag: string } { | |
| 36 | const key = `${version}\n${origin}\n${role}`; | |
| 26 | 37 | let hit = built.get(key); |
| 27 | 38 | if (!hit) { |
| 28 | const text = JSON.stringify(openapi(origin, version)); | |
| 39 | const text = JSON.stringify(openapi(origin, version, role)); | |
| 29 | 40 | hit = { text, etag: weakEtag(text) }; |
| 30 | if (built.size >= MAX) built.delete(built.keys().next().value as string); | |
| 41 | if (built.size >= MAX * ROLES) built.delete(built.keys().next().value as string); | |
| 31 | 42 | built.set(key, hit); |
| 32 | 43 | } |
| 33 | 44 | return hit; |
| @@ -36,14 +47,14 @@ export function openapiText(origin: string, version: string): { text: string; et | ||
| 36 | 47 | /** |
| 37 | 48 | * One tag's slice: the same document with only the paths (and operations) |
| 38 | 49 | * carrying the tag, and the tag list cut to it. Info, servers, security and |
| 39 | * components are unchanged. Built per (origin, version, tag); the tag has | |
| 40 | * been checked against the list, so the cache is bounded by it. | |
| 50 | * components are unchanged. Built per (origin, version, role, tag); the tag | |
| 51 | * has been checked against the list, so the cache is bounded by it. | |
| 41 | 52 | */ |
| 42 | export function openapiSliceText(origin: string, version: string, tag: string): { text: string; etag: string } { | |
| 43 | const key = `${version}\n${origin}\n${tag}`; | |
| 53 | export function openapiSliceText(origin: string, version: string, tag: string, role: Role): { text: string; etag: string } { | |
| 54 | const key = `${version}\n${origin}\n${role}\n${tag}`; | |
| 44 | 55 | let hit = built.get(key); |
| 45 | 56 | if (!hit) { |
| 46 | const doc = openapi(origin, version) as { paths: Record<string, Record<string, { tags: string[] }>>; tags: { name: string }[] }; | |
| 57 | const doc = openapi(origin, version, role) as { paths: Record<string, Record<string, { tags: string[] }>>; tags: { name: string }[] }; | |
| 47 | 58 | const paths: typeof doc.paths = {}; |
| 48 | 59 | for (const [path, ops] of Object.entries(doc.paths)) { |
| 49 | 60 | const kept = Object.entries(ops).filter(([, op]) => op.tags.includes(tag)); |
| @@ -51,7 +62,7 @@ export function openapiSliceText(origin: string, version: string, tag: string): | ||
| 51 | 62 | } |
| 52 | 63 | const text = JSON.stringify({ ...doc, tags: doc.tags.filter((t) => t.name === tag), paths }); |
| 53 | 64 | hit = { text, etag: weakEtag(text) }; |
| 54 | if (built.size >= MAX * 4) built.delete(built.keys().next().value as string); | |
| 65 | if (built.size >= MAX * 4 * ROLES) built.delete(built.keys().next().value as string); | |
| 55 | 66 | built.set(key, hit); |
| 56 | 67 | } |
| 57 | 68 | return hit; |
| @@ -66,8 +77,14 @@ export function openapiRoute(router: Router<Handler>): void { | ||
| 66 | 77 | { status: 400, headers: { "cache-control": "no-store" } }, |
| 67 | 78 | ); |
| 68 | 79 | } |
| 69 | const hit = tag === null ? openapiText(ctx.url.origin, running().version) : openapiSliceText(ctx.url.origin, running().version, tag); | |
| 70 | const headers = { "cache-control": "public, max-age=300", etag: hit.etag, vary: "accept-encoding" }; | |
| 80 | const role = roleOf(ctx.auth); | |
| 81 | const hit = tag === null ? openapiText(ctx.url.origin, running().version, role) : openapiSliceText(ctx.url.origin, running().version, tag, role); | |
| 82 | // The document depends on who asks: a shared cache keeps only the anonymous one. | |
| 83 | const headers = { | |
| 84 | "cache-control": role === "anonymous" ? "public, max-age=300" : "private, max-age=300", | |
| 85 | etag: hit.etag, | |
| 86 | vary: "accept-encoding, authorization, cookie", | |
| 87 | }; | |
| 71 | 88 | if (holds(ctx.request, hit.etag)) return new Response(null, { status: 304, headers }); |
| 72 | 89 | const type = { "content-type": "application/json", ...headers }; |
| 73 | 90 | // Encoded once per build at the slow, small level, not per request. |
src/tui/screens/api.ts +4 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { narrow, reference, runnable, type Area, type Operation } from "../../contract/reference.ts"; |
| 10 | import { openapiText } from "../../contract/served.ts"; | |
| 10 | import { openapiText, roleOf } from "../../contract/served.ts"; | |
| 11 | 11 | import { createSession, destroySession } from "../../core/sessions.ts"; |
| 12 | 12 | import { running } from "../../kernel/version.ts"; |
| 13 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -94,7 +94,9 @@ function answerScreen(ctx: Ctx, op: Operation, answer: Answer): Screen { | ||
| 94 | 94 | export function apiScreen(f: Session, ctx: Ctx): Screen { |
| 95 | 95 | const state = new ListState(); |
| 96 | 96 | const filter = new Filter(); |
| 97 | const all = apiOperations(reference(JSON.parse(openapiText(f.app.config.url ?? "http://localhost", running().version).text))); | |
| 97 | // The rows this person may call, as the page cuts them: a guest reads the anonymous ones (#11286). | |
| 98 | const doc = openapiText(f.app.config.url ?? "http://localhost", running().version, roleOf(f)); | |
| 99 | const all = apiOperations(reference(JSON.parse(doc.text))); | |
| 98 | 100 | const shown = (): Operation[] => apiOperations(narrow([{ name: "", operations: all }], filter.query)).slice(0, MAX_SHOWN); |
| 99 | 101 | let note = ""; |
| 100 | 102 | return { |
src/web/pages/api.ts +16 −12
| @@ -9,7 +9,8 @@ import { knownSpec, refreshSpec } from "../../apps/api_spec.ts"; | ||
| 9 | 9 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 10 | 10 | import { appView } from "../../actions/apps.ts"; |
| 11 | 11 | import { narrow, reference, type Area } from "../../contract/reference.ts"; |
| 12 | import { openapiText } from "../../contract/served.ts"; | |
| 12 | import { openapiText, roleOf } from "../../contract/served.ts"; | |
| 13 | import type { Role } from "../../contract/openapi/shared.ts"; | |
| 13 | 14 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 14 | 15 | import { canEnterApp } from "../../core/repos.ts"; |
| 15 | 16 | import type { Ctx } from "../../http/context.ts"; |
| @@ -24,17 +25,20 @@ const MAX_QUERY = 100; | ||
| 24 | 25 | const asked = (ctx: Ctx): string => (ctx.url.searchParams.get("q") ?? "").trim().slice(0, MAX_QUERY); |
| 25 | 26 | const count = (areas: readonly Area[]): number => areas.reduce((n, a) => n + a.operations.length, 0); |
| 26 | 27 | |
| 27 | /** The forge's reading, once per build of the document. */ | |
| 28 | /** | |
| 29 | * The forge's reading as `role` may see it (#11286): the same cut the | |
| 30 | * document gets, once per build of it and per role. | |
| 31 | */ | |
| 28 | 32 | const forgeAreas = (() => { |
| 29 | let tag = ""; | |
| 30 | let areas: Area[] = []; | |
| 31 | return (origin: string): Area[] => { | |
| 32 | const hit = openapiText(origin, running().version); | |
| 33 | if (hit.etag !== tag) { | |
| 34 | areas = reference(JSON.parse(hit.text)); | |
| 35 | tag = hit.etag; | |
| 33 | const read = new Map<Role, { tag: string; areas: Area[] }>(); | |
| 34 | return (origin: string, role: Role): Area[] => { | |
| 35 | const hit = openapiText(origin, running().version, role); | |
| 36 | let held = read.get(role); | |
| 37 | if (held?.tag !== hit.etag) { | |
| 38 | held = { tag: hit.etag, areas: reference(JSON.parse(hit.text)) }; | |
| 39 | read.set(role, held); | |
| 36 | 40 | } |
| 37 | return areas; | |
| 41 | return held.areas; | |
| 38 | 42 | }; |
| 39 | 43 | })(); |
| 40 | 44 | |
| @@ -44,11 +48,11 @@ export function apiPage(ctx: Ctx): Response { | ||
| 44 | 48 | if (prefersJson(ctx.request)) return noRoute(ctx.request.method, ctx.url.pathname); |
| 45 | 49 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 46 | 50 | const query = asked(ctx); |
| 47 | const all = timePhase(ctx.request, "spec", () => forgeAreas(ctx.url.origin)); | |
| 51 | const all = timePhase(ctx.request, "spec", () => forgeAreas(ctx.url.origin, roleOf(ctx.auth))); | |
| 48 | 52 | const rendering = performance.now(); |
| 49 | 53 | const body = html`<div class="page-head"><h1>API</h1></div> |
| 50 | 54 | <p class="muted"> |
| 51 | Every route the forge names to an agent. <a href="/contract/openapi.json">The OpenAPI document</a>, as JSON. | |
| 55 | Every route the forge names to you: a signed-out reader sees the ones open to anyone. <a href="/contract/openapi.json">The OpenAPI document</a>, as JSON. | |
| 52 | 56 | Try it sends the request as you; paste a token to send it as that token instead. |
| 53 | 57 | </p> |
| 54 | 58 | ${referenceBody({ areas: narrow(all, query), total: count(all), query, action: "/api", tryable: true })}`; |
test/api-admin-drills.test.ts +2 −1
| @@ -7,6 +7,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 7 | 7 | import { createToken } from "../src/core/tokens.ts"; |
| 8 | 8 | import { createUser } from "../src/core/users.ts"; |
| 9 | 9 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 10 | 11 | |
| 11 | 12 | let dataDir = ""; |
| 12 | 13 | let app: App; |
| @@ -79,6 +80,6 @@ test("no token is 401", async () => { | ||
| 79 | 80 | }); |
| 80 | 81 | |
| 81 | 82 | test("openapi lists the route", async () => { |
| 82 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 83 | const doc = (await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).json()) as any; | |
| 83 | 84 | expect(doc.paths["/api/v1/admin/drills"].get).toBeDefined(); |
| 84 | 85 | }); |
test/api-admin-subscriptions.test.ts +2 −1
| @@ -8,6 +8,7 @@ import { addSubscription } from "../src/core/subscriptions.ts"; | ||
| 8 | 8 | import { createToken } from "../src/core/tokens.ts"; |
| 9 | 9 | import { createUser } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 11 | 12 | |
| 12 | 13 | const SECRET = "sk-ant-api03-seeded-secret-text-0123456789abcdef"; |
| 13 | 14 | let dataDir = ""; |
| @@ -74,6 +75,6 @@ test("a member is 403 not_admin, no token is 401", async () => { | ||
| 74 | 75 | }); |
| 75 | 76 | |
| 76 | 77 | test("openapi lists the route", async () => { |
| 77 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 78 | const doc = (await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).json()) as any; | |
| 78 | 79 | expect(doc.paths["/api/v1/admin/subscriptions"].get).toBeDefined(); |
| 79 | 80 | }); |
test/api-cloud-accounts.test.ts +2 −1
| @@ -9,6 +9,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 9 | 9 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | 11 | import { quietLogs } from "./helpers/globals.ts"; |
| 12 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 12 | 13 | |
| 13 | 14 | quietLogs(); |
| 14 | 15 | |
| @@ -83,7 +84,7 @@ describe("GET /api/v1/cloud/accounts", () => { | ||
| 83 | 84 | }); |
| 84 | 85 | |
| 85 | 86 | test("the route is in the OpenAPI document", async () => { |
| 86 | const doc: any = await (await fetch(`${servers.url}/api/v1/openapi.json`)).json(); | |
| 87 | const doc: any = await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).json(); | |
| 87 | 88 | expect(doc.paths["/api/v1/cloud/accounts"].get).toBeDefined(); |
| 88 | 89 | }); |
| 89 | 90 | }); |
test/api-crew-now.test.ts +2 −1
| @@ -18,6 +18,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 18 | 18 | import { seedAdmin } from "./helpers/admin.ts"; |
| 19 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| 20 | 20 | import { mcpTool } from "./helpers/mcp.ts"; |
| 21 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 21 | 22 | |
| 22 | 23 | quietLogs(); |
| 23 | 24 | |
| @@ -132,7 +133,7 @@ describe("the crew's work right now over the API and MCP", () => { | ||
| 132 | 133 | }); |
| 133 | 134 | |
| 134 | 135 | test("openapi lists the route", async () => { |
| 135 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 136 | const doc = (await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).json()) as any; | |
| 136 | 137 | expect(doc.paths["/api/v1/crew/now"].get).toBeDefined(); |
| 137 | 138 | }); |
| 138 | 139 | }); |
test/api-data.test.ts +2 −1
| @@ -15,6 +15,7 @@ import { createUser } from "../src/core/users.ts"; | ||
| 15 | 15 | import { listSources } from "../src/data/sources.ts"; |
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | 17 | import { quietLogs } from "./helpers/globals.ts"; |
| 18 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 18 | 19 | |
| 19 | 20 | quietLogs(); |
| 20 | 21 | |
| @@ -157,7 +158,7 @@ test("a cross-origin session post is refused with a 403; the same-origin one run | ||
| 157 | 158 | }); |
| 158 | 159 | |
| 159 | 160 | test("the spec lists all four routes", async () => { |
| 160 | const spec = (await (await fetch(`${servers.url}/api/v1/openapi.json`, { redirect: "follow" })).json()) as any; | |
| 161 | const spec = (await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).json()) as any; | |
| 161 | 162 | const paths = Object.keys(spec.paths); |
| 162 | 163 | for (const p of ["/data", "/data/{env}/{file}/tables", "/data/{env}/{file}/rows", "/data/{env}/{file}/query"]) |
| 163 | 164 | expect(paths).toContain(`/api/v1/repos/{owner}/{name}${p}`); |
test/api-flags.test.ts +2 −1
| @@ -11,6 +11,7 @@ import { createUser, getUserByName } from "../src/core/users.ts"; | ||
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { useFlagRegistry } from "../src/web/pages/flags.ts"; |
| 13 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 14 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 14 | 15 | |
| 15 | 16 | quietLogs(); |
| 16 | 17 | |
| @@ -75,7 +76,7 @@ describe("GET /api/v1/flags", () => { | ||
| 75 | 76 | }); |
| 76 | 77 | |
| 77 | 78 | test("the route is in the OpenAPI document", async () => { |
| 78 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 79 | const doc = (await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).json()) as any; | |
| 79 | 80 | expect(doc.paths["/api/v1/flags"].get).toBeDefined(); |
| 80 | 81 | }); |
| 81 | 82 | }); |
test/api-page-param.test.ts +1 −1
| @@ -77,7 +77,7 @@ test("a list that does not page by cursor ignores page", async () => { | ||
| 77 | 77 | }); |
| 78 | 78 | |
| 79 | 79 | test("the contract names the 400 on every route the guard covers", () => { |
| 80 | const doc = openapi("http://x", "0") as { paths: Record<string, Record<string, { responses: Record<string, unknown> }>> }; | |
| 80 | const doc = openapi("http://x", "0", "admin") as { paths: Record<string, Record<string, { responses: Record<string, unknown> }>> }; | |
| 81 | 81 | let seen = 0; |
| 82 | 82 | for (const r of ROUTES) { |
| 83 | 83 | const refused = r.method !== "get" ? undefined : pageParamRefused(r.path.replace(/\{(\w+)\}/g, ":$1"), new URLSearchParams("page=2")); |
test/api-reference.test.ts +32 −6
| @@ -7,7 +7,7 @@ import { readSpec, rememberSpec } from "../src/apps/api_spec.ts"; | ||
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | import { narrow, reference, runnable } from "../src/contract/reference.ts"; |
| 9 | 9 | import { ROUTES } from "../src/contract/openapi.ts"; |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 13 | 13 | import { apiScreen, runGet } from "../src/tui/screens/api.ts"; |
| @@ -48,13 +48,26 @@ describe("the API reference", () => { | ||
| 48 | 48 | await rm(dir, { recursive: true, force: true }); |
| 49 | 49 | }); |
| 50 | 50 | |
| 51 | test("every route in the contract appears on /api, with no script and no CDN", async () => { | |
| 51 | const anchor = (r: (typeof ROUTES)[number]): string => `id="${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}"`; | |
| 52 | ||
| 53 | test("every route in the contract appears on /api to an admin, with no script and no CDN", async () => { | |
| 54 | const res = await get("/api", { cookie }); | |
| 55 | expect(res.status).toBe(200); | |
| 56 | const body = await res.text(); | |
| 57 | for (const r of ROUTES) expect(body).toContain(anchor(r)); | |
| 58 | expect(body).toContain("<details"); | |
| 59 | expect(body).not.toMatch(/https?:\/\/(?!localhost|127\.0\.0\.1)[a-z.]*cdn/i); | |
| 60 | }); | |
| 61 | ||
| 62 | test("/api read signed out shows only the anonymous rows: no admin path (#11286)", async () => { | |
| 52 | 63 | const res = await get("/api"); |
| 53 | 64 | expect(res.status).toBe(200); |
| 54 | 65 | const body = await res.text(); |
| 55 | for (const r of ROUTES) expect(body).toContain(`id="${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}"`); | |
| 56 | expect(body).toContain("<details"); | |
| 57 | expect(body).not.toMatch(/https?:\/\/(?!localhost|127\.0\.0\.1)[a-z.]*cdn/i); | |
| 66 | const admin = ROUTES.filter((r) => r.role === "admin"); | |
| 67 | expect(admin.length).toBeGreaterThan(0); | |
| 68 | for (const r of admin) expect(body, r.path).not.toContain(anchor(r)); | |
| 69 | for (const r of ROUTES.filter((r) => r.role === "member")) expect(body, r.path).not.toContain(anchor(r)); | |
| 70 | for (const r of ROUTES.filter((r) => r.role === "anonymous")) expect(body, r.path).toContain(anchor(r)); | |
| 58 | 71 | }); |
| 59 | 72 | |
| 60 | 73 | test("the search narrows by a word and says how many matched", async () => { |
| @@ -112,12 +125,25 @@ describe("the API reference", () => { | ||
| 112 | 125 | tui.start(); |
| 113 | 126 | tui.ctx.push(apiScreen(session, tui.ctx)); |
| 114 | 127 | await settleApp(tui); |
| 115 | expect(tui.screen.toText()).toContain("operations"); | |
| 128 | // A guest at the terminal reads what a signed-out page does (#11286). | |
| 129 | expect(tui.screen.toText()).toContain(`${ROUTES.filter((r) => r.role === "anonymous").length} operations`); | |
| 116 | 130 | const answer = await runGet(session, "/api/v1/openapi.json"); |
| 117 | 131 | expect(answer.status).toBe(200); |
| 118 | 132 | expect(answer.body).toContain('"openapi"'); |
| 119 | 133 | }); |
| 120 | 134 | |
| 135 | test("the terminal's API screen shows an admin every operation (#11286)", async () => { | |
| 136 | const terminal = headlessTerminal(120, 30); | |
| 137 | const user = getUserByName(app.db, "alice")!; | |
| 138 | expect(user.isAdmin).toBe(true); | |
| 139 | const identity = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:admin", user }; | |
| 140 | const { app: tui, session } = createForgeApp(app, identity, { terminal, exitHooks: false }); | |
| 141 | tui.start(); | |
| 142 | tui.ctx.push(apiScreen(session, tui.ctx)); | |
| 143 | await settleApp(tui); | |
| 144 | expect(tui.screen.toText()).toContain(`${ROUTES.length} operations`); | |
| 145 | }); | |
| 146 | ||
| 121 | 147 | test("readSpec stops reading a body past the cap instead of buffering it", async () => { |
| 122 | 148 | let sent = 0; |
| 123 | 149 | const big = Bun.serve({ |
test/api-user-notify.test.ts +2 −1
| @@ -11,6 +11,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 11 | 11 | import { createUser } from "../src/core/users.ts"; |
| 12 | 12 | import { serve, type Servers } from "../src/http/server.ts"; |
| 13 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 14 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 14 | 15 | |
| 15 | 16 | quietLogs(); |
| 16 | 17 | |
| @@ -95,6 +96,6 @@ test("refusals: agent, scope, bad URL, bad body, cross-site, anonymous", async ( | ||
| 95 | 96 | }); |
| 96 | 97 | |
| 97 | 98 | test("the OpenAPI document lists both", async () => { |
| 98 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as { paths: Record<string, Record<string, unknown>> }; | |
| 99 | const doc = (await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).json()) as { paths: Record<string, Record<string, unknown>> }; | |
| 99 | 100 | expect(Object.keys(doc.paths["/api/v1/user/notify"] ?? {}).sort()).toEqual(["get", "put"]); |
| 100 | 101 | }); |
test/billing-links.test.ts +2 −1
| @@ -17,6 +17,7 @@ import { getBillingLinks, parseBillingLinks, sizeWords } from "../src/settings/b | ||
| 17 | 17 | import { quietLogs } from "./helpers/globals.ts"; |
| 18 | 18 | import { mcpTool } from "./helpers/mcp.ts"; |
| 19 | 19 | import { promote } from "./helpers/members.ts"; |
| 20 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 20 | 21 | |
| 21 | 22 | quietLogs(); |
| 22 | 23 | |
| @@ -237,6 +238,6 @@ test("PUT /api/v1/admin/billing/links replaces the links; a member and a bad lin | ||
| 237 | 238 | expect(getBillingLinks(app.db)).toEqual(LINKS); |
| 238 | 239 | await put(adminToken, {}); |
| 239 | 240 | expect(getBillingLinks(app.db)).toEqual({}); |
| 240 | const spec = await (await fetch(`${servers.url}/api/v1/openapi.json`)).text(); | |
| 241 | const spec = await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).text(); | |
| 241 | 242 | expect(spec).toContain("/api/v1/admin/billing/links"); |
| 242 | 243 | }); |
test/build-pin-refusals.test.ts +2 −1
| @@ -14,6 +14,7 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 14 | 14 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 17 | 18 | |
| 18 | 19 | let dataDir: string; |
| 19 | 20 | let app: App; |
| @@ -70,7 +71,7 @@ afterAll(async () => { | ||
| 70 | 71 | }); |
| 71 | 72 | |
| 72 | 73 | test("every error POST …/builds answers is a refusal the OpenAPI document lists", async () => { |
| 73 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 74 | const doc = (await (await adminOpenapi(app, servers.url, "/api/v1/openapi.json")).json()) as any; | |
| 74 | 75 | const op = doc.paths["/api/v1/repos/{owner}/{name}/builds"].post; |
| 75 | 76 | const refusals = String(op.responses.default.description).split("Refusals: ")[1] ?? ""; |
| 76 | 77 | const codes = [...refusals.matchAll(/`([a-z_]+)` \(/g)].map((m) => m[1]!); |
test/contract-openapi-roles.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { createToken } from "../src/core/tokens.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { ROUTES } from "../src/contract/openapi.ts"; | |
| 10 | import { openapiRoute } from "../src/contract/served.ts"; | |
| 11 | import { Router } from "../src/http/router.ts"; | |
| 12 | ||
| 13 | type Op = { "x-role": string; tags: string[] }; | |
| 14 | type Doc = { paths: Record<string, Record<string, Op>>; tags: { name: string }[] }; | |
| 15 | ||
| 16 | const router = new Router<(ctx: any) => Response>(); | |
| 17 | openapiRoute(router as never); | |
| 18 | const handler = (router as any).match("GET", "/contract/openapi.json").handler; | |
| 19 | const ask = (user: { isAdmin: boolean } | undefined, q = "", headers: Record<string, string> = {}): Response => { | |
| 20 | const url = new URL(`https://roles.test/contract/openapi.json${q}`); | |
| 21 | return handler({ url, request: new Request(url.href, { headers }), auth: { user, via: user ? "token" : "none" } }); | |
| 22 | }; | |
| 23 | const ops = (doc: Doc): Op[] => Object.values(doc.paths).flatMap((o) => Object.values(o)); | |
| 24 | const roles = (doc: Doc): Set<string> => new Set(ops(doc).map((o) => o["x-role"])); | |
| 25 | const count = (...rs: string[]): number => ROUTES.filter((r) => rs.includes(r.role)).length; | |
| 26 | ||
| 27 | test("anonymous sees anonymous rows, a member adds member rows, an admin sees all three", async () => { | |
| 28 | const anon = (await ask(undefined).json()) as Doc; | |
| 29 | const member = (await ask({ isAdmin: false }).json()) as Doc; | |
| 30 | const admin = (await ask({ isAdmin: true }).json()) as Doc; | |
| 31 | expect([...roles(anon)]).toEqual(["anonymous"]); | |
| 32 | expect([...roles(member)].sort()).toEqual(["anonymous", "member"]); | |
| 33 | expect([...roles(admin)].sort()).toEqual(["admin", "anonymous", "member"]); | |
| 34 | expect(ops(anon).length).toBe(count("anonymous")); | |
| 35 | expect(ops(member).length).toBe(count("anonymous", "member")); | |
| 36 | expect(ops(admin).length).toBe(ROUTES.length); | |
| 37 | // No admin path reaches the first two, nor a tag only admin rows carry. | |
| 38 | const adminOnly = ROUTES.filter((r) => r.role === "admin").map((r) => `${r.method} ${r.path}`); | |
| 39 | expect(adminOnly.length).toBeGreaterThan(0); | |
| 40 | for (const doc of [anon, member]) { | |
| 41 | const seen = new Set(Object.entries(doc.paths).flatMap(([p, o]) => Object.keys(o).map((m) => `${m} ${p}`))); | |
| 42 | for (const row of adminOnly) expect(seen.has(row), row).toBe(false); | |
| 43 | const used = new Set(ops(doc).flatMap((o) => o.tags)); | |
| 44 | expect(doc.tags.every((t) => used.has(t.name))).toBe(true); | |
| 45 | } | |
| 46 | }); | |
| 47 | ||
| 48 | test("each role has its own tag; only the anonymous answer is public, and every answer varies on credentials", async () => { | |
| 49 | const [a, m, ad] = [ask(undefined), ask({ isAdmin: false }), ask({ isAdmin: true })]; | |
| 50 | const tags = [a, m, ad].map((r) => r.headers.get("etag")); | |
| 51 | expect(new Set(tags).size).toBe(3); | |
| 52 | expect(a.headers.get("cache-control")).toStartWith("public"); | |
| 53 | expect(m.headers.get("cache-control")).toStartWith("private"); | |
| 54 | expect(ad.headers.get("cache-control")).toStartWith("private"); | |
| 55 | for (const r of [a, m, ad]) expect(r.headers.get("vary")).toContain("authorization, cookie"); | |
| 56 | // The admin's tag does not answer 304 to an anonymous asker. | |
| 57 | expect(ask(undefined, "", { "if-none-match": tags[2]! }).status).toBe(200); | |
| 58 | expect(ask({ isAdmin: true }, "", { "if-none-match": tags[2]! }).status).toBe(304); | |
| 59 | }); | |
| 60 | ||
| 61 | test("?tag= slices per role", async () => { | |
| 62 | const tag = ROUTES.find((r) => r.role === "admin")!; | |
| 63 | const name = ((await ask({ isAdmin: true }).json()) as Doc).paths[tag.path]![tag.method]!.tags[0]!; | |
| 64 | const anon = (await ask(undefined, `?tag=${name}`).json()) as Doc; | |
| 65 | const admin = (await ask({ isAdmin: true }, `?tag=${name}`).json()) as Doc; | |
| 66 | expect(roles(anon).has("admin")).toBe(false); | |
| 67 | expect(admin.paths[tag.path]?.[tag.method]).toBeDefined(); | |
| 68 | for (const o of [...ops(anon), ...ops(admin)]) expect(o.tags).toEqual([name]); | |
| 69 | }); | |
| 70 | ||
| 71 | describe("over HTTP, a bearer token reaches the cut (#11286)", () => { | |
| 72 | let dir = ""; | |
| 73 | let app: App; | |
| 74 | let servers: Servers; | |
| 75 | const tokens: Record<string, string> = {}; | |
| 76 | const read = (token?: string) => | |
| 77 | fetch(`${servers.url}/contract/openapi.json`, { headers: token ? { authorization: `Bearer ${token}` } : {} }); | |
| 78 | ||
| 79 | beforeAll(async () => { | |
| 80 | dir = await mkdtemp(join(tmpdir(), "forge-openapi-roles-")); | |
| 81 | app = createApp({ dataDir: dir, port: 0 }); | |
| 82 | servers = await serve(app); | |
| 83 | for (const [name, isAdmin] of [["carol", true], ["bob", false]] as const) { | |
| 84 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@f.dev`, password: "a-good-passphrase", isAdmin }); | |
| 85 | if (!made.ok) throw new Error(made.error); | |
| 86 | tokens[name] = createToken(app.db, made.value.id, "t", "ro").token; | |
| 87 | } | |
| 88 | }, 60_000); | |
| 89 | ||
| 90 | afterAll(async () => { | |
| 91 | servers.stop(); | |
| 92 | app.close(); | |
| 93 | await rm(dir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("anonymous, a member's token and an admin's token each get their own rows and cache rule", async () => { | |
| 97 | const [anon, member, admin] = await Promise.all([read(), read(tokens.bob), read(tokens.carol)]); | |
| 98 | expect(anon.headers.get("cache-control")).toStartWith("public"); | |
| 99 | expect(member.headers.get("cache-control")).toStartWith("private"); | |
| 100 | expect(admin.headers.get("cache-control")).toStartWith("private"); | |
| 101 | expect([...roles((await anon.json()) as Doc)]).toEqual(["anonymous"]); | |
| 102 | expect([...roles((await member.json()) as Doc)].sort()).toEqual(["anonymous", "member"]); | |
| 103 | expect(ops((await admin.json()) as Doc).length).toBe(ROUTES.length); | |
| 104 | // A token the forge does not know is no one: the anonymous cut. | |
| 105 | const stranger = await read("forge_not_a_real_token"); | |
| 106 | expect([401, 200]).toContain(stranger.status); | |
| 107 | if (stranger.status === 200) expect([...roles((await stranger.json()) as Doc)]).toEqual(["anonymous"]); | |
| 108 | }); | |
| 109 | }); |
test/contract-openapi-tags.test.ts +6 −4
| @@ -4,7 +4,7 @@ import { TAG_WORDS, tagOf } from "../src/contract/tags.ts"; | ||
| 4 | 4 | import { openapiSliceText, TAGS } from "../src/contract/served.ts"; |
| 5 | 5 | |
| 6 | 6 | type Doc = { tags: { name: string; description: string }[]; paths: Record<string, Record<string, { tags: string[] }>>; components: unknown; info: unknown }; |
| 7 | const doc = openapi("https://x.test", "v") as Doc; | |
| 7 | const doc = openapi("https://x.test", "v", "admin") as Doc; | |
| 8 | 8 | |
| 9 | 9 | test("every operation carries exactly one tag, and every tag is listed with a line", () => { |
| 10 | 10 | const listed = new Set(doc.tags.map((t) => t.name)); |
| @@ -25,13 +25,13 @@ test("a path's tag is its resource", () => { | ||
| 25 | 25 | }); |
| 26 | 26 | |
| 27 | 27 | test("a slice keeps only its tag's operations and the rest of the document", () => { |
| 28 | const slice = JSON.parse(openapiSliceText("https://x.test", "v", "pulls").text) as Doc; | |
| 28 | const slice = JSON.parse(openapiSliceText("https://x.test", "v", "pulls", "admin").text) as Doc; | |
| 29 | 29 | expect(slice.info).toEqual(doc.info); |
| 30 | 30 | expect(slice.components).toEqual(doc.components); |
| 31 | 31 | expect(slice.tags.map((t) => t.name)).toEqual(["pulls"]); |
| 32 | 32 | const n = Object.values(slice.paths).flatMap((o) => Object.values(o)).length; |
| 33 | 33 | expect(n).toBe(ROUTES.filter((r) => tagOf(r.path) === "pulls").length); |
| 34 | expect(openapiSliceText("https://x.test", "v", "pulls").text.length).toBeLessThan(40_000); | |
| 34 | expect(openapiSliceText("https://x.test", "v", "pulls", "admin").text.length).toBeLessThan(40_000); | |
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | 37 | test("?tag= over HTTP: a slice, the whole, and a 400 naming the tags", async () => { |
| @@ -48,7 +48,9 @@ test("?tag= over HTTP: a slice, the whole, and a 400 naming the tags", async () | ||
| 48 | 48 | expect(bad.status).toBe(400); |
| 49 | 49 | expect(((await bad.json()) as { message: string }).message).toContain(TAGS.join(", ")); |
| 50 | 50 | const whole = JSON.parse(await (await call("")).text()) as Doc; |
| 51 | expect(Object.keys(whole.paths).length).toBe(Object.keys(doc.paths).length); | |
| 51 | // No account asked: the whole is the anonymous cut (#11286). | |
| 52 | const anonymous = openapi("https://x.test", "v", "anonymous") as Doc; | |
| 53 | expect(Object.keys(whole.paths).length).toBe(Object.keys(anonymous.paths).length); | |
| 52 | 54 | const one = JSON.parse(await (await call("?tag=pulls")).text()) as Doc; |
| 53 | 55 | expect(Object.keys(one.paths).length).toBeLessThan(Object.keys(whole.paths).length); |
| 54 | 56 | }); |
test/helpers/openapi-doc.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | import type { App } from "../../src/app.ts"; | |
| 2 | import { adminCookie } from "./admin.ts"; | |
| 3 | ||
| 4 | const cookies = new WeakMap<App, Promise<string>>(); | |
| 5 | ||
| 6 | /** | |
| 7 | * The served OpenAPI document as a site admin reads it: every row. The | |
| 8 | * document is cut to the caller's role (#11286), so a suite that checks a | |
| 9 | * member or admin route is described asks as an admin, signed in once per app. | |
| 10 | */ | |
| 11 | export async function adminOpenapi(app: App, base: string, path = "/contract/openapi.json"): Promise<Response> { | |
| 12 | let cookie = cookies.get(app); | |
| 13 | if (!cookie) { | |
| 14 | cookie = adminCookie(app, { username: "openapi-reader", email: "[email protected]", password: "openapi-reader-pass-1" }); | |
| 15 | cookies.set(app, cookie); | |
| 16 | } | |
| 17 | return fetch(`${base}${path}`, { headers: { cookie: await cookie }, redirect: "follow" }); | |
| 18 | } |
test/mcp.test.ts +3 −2
| @@ -34,6 +34,7 @@ import { promote } from "./helpers/members.ts"; | ||
| 34 | 34 | import { showBoard } from "../src/actions/boards.ts"; |
| 35 | 35 | import { panelData } from "../src/boards/panel_data.ts"; |
| 36 | 36 | import { listVia } from "./mcp-list.ts"; |
| 37 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 37 | 38 | |
| 38 | 39 | /** tools/list's order: the tools by name, then `report`. */ |
| 39 | 40 | const sortedThenReport = (names: string[]): string[] => [ |
| @@ -1589,14 +1590,14 @@ describe("the forge over MCP", () => { | ||
| 1589 | 1590 | }); |
| 1590 | 1591 | |
| 1591 | 1592 | test("GET /contract/openapi.json describes the public API", async () => { |
| 1592 | const res = await fetch(`${base}/contract/openapi.json`); | |
| 1593 | const res = await adminOpenapi(app, base, "/contract/openapi.json"); | |
| 1593 | 1594 | expect(res.status).toBe(200); |
| 1594 | 1595 | const doc = (await res.json()) as any; |
| 1595 | 1596 | expect(doc.openapi).toBe("3.1.0"); |
| 1596 | 1597 | expect(doc.paths["/mcp"].post).toBeDefined(); |
| 1597 | 1598 | }); |
| 1598 | 1599 | test("GET /contract/openapi.json serves the triage routes", async () => { |
| 1599 | const doc = (await (await fetch(`${base}/contract/openapi.json`)).json()) as any; | |
| 1600 | const doc = (await (await adminOpenapi(app, base, "/contract/openapi.json")).json()) as any; | |
| 1600 | 1601 | const R = "/api/v1/repos/{owner}/{name}"; |
| 1601 | 1602 | expect(doc.paths[`${R}/issues/{number}/teams`].get).toBeDefined(); |
| 1602 | 1603 | expect(doc.paths[`${R}/issues/{number}/teams`].put).toBeDefined(); |
test/openapi-account.test.ts +2 −1
| @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 8 | 9 | |
| 9 | 10 | let dataDir: string; |
| 10 | 11 | let app: App; |
| @@ -23,7 +24,7 @@ afterAll(async () => { | ||
| 23 | 24 | }); |
| 24 | 25 | |
| 25 | 26 | test("the served document has the password and email verify routes", async () => { |
| 26 | const res = await fetch(`${servers.url}/contract/openapi.json`); | |
| 27 | const res = await adminOpenapi(app, servers.url, "/contract/openapi.json"); | |
| 27 | 28 | expect(res.status).toBe(200); |
| 28 | 29 | const doc = (await res.json()) as any; |
| 29 | 30 | const password = doc.paths["/api/v1/user/password"]?.post; |
test/openapi-drift.test.ts +1 −1
| @@ -70,7 +70,7 @@ const PINNED: Readonly<Record<string, "anonymous" | "member" | "admin">> = { | ||
| 70 | 70 | }; |
| 71 | 71 | |
| 72 | 72 | test("every operation names its role, as the pinned list says", () => { |
| 73 | const doc = openapi("https://x.test", "0") as { paths: Record<string, Record<string, { "x-role": string }>> }; | |
| 73 | const doc = openapi("https://x.test", "0", "admin") as { paths: Record<string, Record<string, { "x-role": string }>> }; | |
| 74 | 74 | const byKey = new Map(ROUTES.map((r) => [`${r.method.toUpperCase()} ${r.path.replace(/\{owner\}\/\{name\}/, "{owner}/{name}")}`, r])); |
| 75 | 75 | for (const [k, role] of Object.entries(PINNED)) { |
| 76 | 76 | const r = byKey.get(k); |
test/openapi-flow.test.ts +5 −4
| @@ -6,6 +6,7 @@ import { join } from "node:path"; | ||
| 6 | 6 | import { OAUTH_SCOPES } from "../src/core/oauth.ts"; |
| 7 | 7 | import { createApp } from "../src/app.ts"; |
| 8 | 8 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 9 | 10 | |
| 10 | 11 | let dataDir: string; |
| 11 | 12 | let servers: Servers; |
| @@ -23,7 +24,7 @@ afterAll(async () => { | ||
| 23 | 24 | }); |
| 24 | 25 | |
| 25 | 26 | test("GET /contract/openapi.json has the flow and the scorecard", async () => { |
| 26 | const res = await fetch(`${servers.url}/contract/openapi.json`); | |
| 27 | const res = await adminOpenapi(app, servers.url, "/contract/openapi.json"); | |
| 27 | 28 | expect(res.status).toBe(200); |
| 28 | 29 | const doc = (await res.json()) as any; |
| 29 | 30 | |
| @@ -40,7 +41,7 @@ test("GET /contract/openapi.json has the flow and the scorecard", async () => { | ||
| 40 | 41 | }); |
| 41 | 42 | |
| 42 | 43 | test("the document describes OAuth: authorization code, refresh, and OpenID discovery", async () => { |
| 43 | const doc = (await (await fetch(`${servers.url}/contract/openapi.json`)).json()) as any; | |
| 44 | const doc = (await (await adminOpenapi(app, servers.url, "/contract/openapi.json")).json()) as any; | |
| 44 | 45 | const schemes = doc.components.securitySchemes; |
| 45 | 46 | const flow = schemes.oauth2.flows.authorizationCode; |
| 46 | 47 | expect(flow.refreshUrl.endsWith("/oauth/token")).toBe(true); |
| @@ -55,7 +56,7 @@ test("the document describes OAuth: authorization code, refresh, and OpenID disc | ||
| 55 | 56 | }); |
| 56 | 57 | |
| 57 | 58 | test("the served document says the telemetry flow read can be revalidated with a 304", async () => { |
| 58 | const doc = (await (await fetch(`${servers.url}/contract/openapi.json`)).json()) as any; | |
| 59 | const doc = (await (await adminOpenapi(app, servers.url, "/contract/openapi.json")).json()) as any; | |
| 59 | 60 | const op = doc.paths["/api/v1/telemetry/flow"].get; |
| 60 | 61 | expect(op.responses["304"]).toBeDefined(); |
| 61 | 62 | expect(op.summary).toContain("cacheable by ETag"); |
| @@ -63,7 +64,7 @@ test("the served document says the telemetry flow read can be revalidated with a | ||
| 63 | 64 | }); |
| 64 | 65 | |
| 65 | 66 | test("the description declares its origin, a bearer default, and marks open routes", async () => { |
| 66 | const doc = (await (await fetch(`${servers.url}/contract/openapi.json`)).json()) as any; | |
| 67 | const doc = (await (await adminOpenapi(app, servers.url, "/contract/openapi.json")).json()) as any; | |
| 67 | 68 | expect(doc.servers).toEqual([{ url: servers.url }]); |
| 68 | 69 | expect(doc.components.securitySchemes.token).toEqual({ type: "http", scheme: "bearer" }); |
| 69 | 70 | expect(doc.security).toEqual([{ token: [] }]); |
test/openapi-limit-bounds.test.ts +2 −1
| @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 8 | 9 | |
| 9 | 10 | let dataDir: string; |
| 10 | 11 | let app: App; |
| @@ -16,7 +17,7 @@ beforeAll(async () => { | ||
| 16 | 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-openapi-limit-")); |
| 17 | 18 | app = createApp({ dataDir, port: 0 }); |
| 18 | 19 | servers = await serve(app); |
| 19 | doc = (await (await fetch(`${servers.url}/contract/openapi.json`)).json()) as typeof doc; | |
| 20 | doc = (await (await adminOpenapi(app, servers.url, "/contract/openapi.json")).json()) as typeof doc; | |
| 20 | 21 | }); |
| 21 | 22 | |
| 22 | 23 | afterAll(async () => { |
test/openapi-milestone-watch.test.ts +1 −1
| @@ -5,7 +5,7 @@ import { openapi } from "../src/contract/openapi.ts"; | ||
| 5 | 5 | const PATH = "/api/v1/repos/{owner}/{name}/milestones/{number}/watch"; |
| 6 | 6 | |
| 7 | 7 | test("PUT and DELETE watch each have a row with the refusals the handler answers", () => { |
| 8 | const doc = openapi("https://f.test", "1") as any; | |
| 8 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 9 | 9 | for (const m of ["put", "delete"]) { |
| 10 | 10 | const op = doc.paths[PATH]?.[m]; |
| 11 | 11 | expect(op, m).toBeDefined(); |
test/openapi-milestones-list.test.ts +1 −1
| @@ -3,7 +3,7 @@ import { expect, test } from "bun:test"; | ||
| 3 | 3 | import { openapi } from "../src/contract/openapi.ts"; |
| 4 | 4 | |
| 5 | 5 | test("GET milestones summary names the page shape, not { repo, limbs }", () => { |
| 6 | const doc = openapi("https://f.test", "1") as any; | |
| 6 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 7 | 7 | const op = doc.paths["/api/v1/repos/{owner}/{name}/milestones"]?.get; |
| 8 | 8 | expect(op).toBeDefined(); |
| 9 | 9 | expect(op.summary).toContain("{ items, next_cursor }"); |
test/openapi-oa-5242.test.ts +1 −1
| @@ -23,7 +23,7 @@ const WANT: readonly (readonly [string, string, string])[] = [ | ||
| 23 | 23 | ]; |
| 24 | 24 | |
| 25 | 25 | test("each path and method is served, with a refusal code the route answers", () => { |
| 26 | const doc = openapi("https://f.test", "1") as any; | |
| 26 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 27 | 27 | for (const [path, m, code] of WANT) { |
| 28 | 28 | const op = doc.paths[path]?.[m]; |
| 29 | 29 | expect(op, `${m} ${path}`).toBeDefined(); |
test/openapi-oa-acct.test.ts +3 −3
| @@ -13,18 +13,18 @@ const WANT: readonly (readonly [string, string])[] = [ | ||
| 13 | 13 | ]; |
| 14 | 14 | |
| 15 | 15 | test("each admin account and invite route is in the served document", () => { |
| 16 | const doc = openapi("https://f.test", "1") as any; | |
| 16 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 17 | 17 | for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); |
| 18 | 18 | }); |
| 19 | 19 | |
| 20 | 20 | test("the invite row says the link is shown once", () => { |
| 21 | const doc = openapi("https://f.test", "1") as any; | |
| 21 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 22 | 22 | expect(doc.paths["/api/v1/admin/invites"].post.summary).toContain("shown once"); |
| 23 | 23 | expect(doc.paths["/api/v1/admin/invites"].post.security).toBeDefined(); |
| 24 | 24 | }); |
| 25 | 25 | |
| 26 | 26 | test("the password and email-verify rows carry their bodies and refusals", () => { |
| 27 | const doc = openapi("https://f.test", "1") as any; | |
| 27 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 28 | 28 | const password = doc.paths["/api/v1/user/password"]?.post; |
| 29 | 29 | const verify = doc.paths["/api/v1/user/email/verify"]?.post; |
| 30 | 30 | expect(Object.keys(password.requestBody.content["application/json"].schema.properties).sort()).toEqual(["current", "next"]); |
test/openapi-oa-agents.test.ts +1 −1
| @@ -26,7 +26,7 @@ const want: readonly (readonly [string, string])[] = [ | ||
| 26 | 26 | ]; |
| 27 | 27 | |
| 28 | 28 | test("each agent and crew door is in the served document", () => { |
| 29 | const doc = openapi("https://f.test", "1") as any; | |
| 29 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 30 | 30 | for (const [method, path] of want) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); |
| 31 | 31 | expect(doc.paths["/api/v1/thoughts"].post.security).toBeDefined(); |
| 32 | 32 | expect(doc.paths["/api/v1/here"].get.security).toEqual([]); |
test/openapi-oa-browse.test.ts +1 −1
| @@ -29,7 +29,7 @@ afterAll(async () => { | ||
| 29 | 29 | }); |
| 30 | 30 | |
| 31 | 31 | test("each browse path is a GET in the document, with its refusals and untrusted note", () => { |
| 32 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>; | |
| 32 | const paths = openapi("http://x", "1", "admin").paths as Record<string, Record<string, any>>; | |
| 33 | 33 | for (const path of PATHS) { |
| 34 | 34 | const get = paths[path]?.get; |
| 35 | 35 | expect(get, path).toBeDefined(); |
test/openapi-oa-crew.test.ts +1 −1
| @@ -17,7 +17,7 @@ const WANT: Record<string, string[]> = { | ||
| 17 | 17 | }; |
| 18 | 18 | |
| 19 | 19 | test("each crew-area path and method is served", () => { |
| 20 | const doc = openapi("https://f.test", "1") as any; | |
| 20 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 21 | 21 | for (const [path, methods] of Object.entries(WANT)) |
| 22 | 22 | for (const m of methods) expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined(); |
| 23 | 23 | expect(doc.paths[`${R}/hooks`].post.security).toBeDefined(); |
test/openapi-oa-feed.test.ts +1 −1
| @@ -10,7 +10,7 @@ import { tokenFor } from "./helpers/tokens.ts"; | ||
| 10 | 10 | import { openapi } from "../src/contract/openapi.ts"; |
| 11 | 11 | |
| 12 | 12 | test("both writes are in the document with the refusals they answer", () => { |
| 13 | const doc = openapi("https://f.test", "1") as any; | |
| 13 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 14 | 14 | for (const [path, codes] of [ |
| 15 | 15 | ["/api/v1/feed/interactions", ["invalid_item", "invalid_kind", "not_found"]], |
| 16 | 16 | ["/api/v1/share", ["invalid_item", "invalid_target", "not_found"]], |
test/openapi-oa-forges.test.ts +2 −2
| @@ -21,12 +21,12 @@ const WANT: readonly (readonly [string, string])[] = [ | ||
| 21 | 21 | ]; |
| 22 | 22 | |
| 23 | 23 | test("each forges and fleet path and method is in the document", () => { |
| 24 | const doc = openapi("https://f.test", "1") as any; | |
| 24 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 25 | 25 | for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | 28 | test("the claim link is shown once and the heartbeat and drills say who calls them", () => { |
| 29 | const doc = openapi("https://f.test", "1") as any; | |
| 29 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 30 | 30 | expect(doc.paths["/api/v1/forges/{name}/claim-link"].post.summary).toContain("shown once"); |
| 31 | 31 | expect(doc.paths["/api/v1/heartbeat"].post.summary).toContain("child"); |
| 32 | 32 | expect(doc.paths["/api/v1/heartbeat"].post.security).toEqual([]); |
test/openapi-oa-fork.test.ts +1 −1
| @@ -23,6 +23,6 @@ const WANT: readonly [string, string][] = [ | ||
| 23 | 23 | ]; |
| 24 | 24 | |
| 25 | 25 | test("each fork-tree path and method is in the document", () => { |
| 26 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, unknown>>; | |
| 26 | const paths = openapi("http://x", "1", "admin").paths as Record<string, Record<string, unknown>>; | |
| 27 | 27 | for (const [method, path] of WANT) expect(paths[path]?.[method]).toBeDefined(); |
| 28 | 28 | }); |
test/openapi-oa-passport.test.ts +1 −1
| @@ -3,7 +3,7 @@ import { expect, test } from "bun:test"; | ||
| 3 | 3 | import { openapi } from "../src/contract/openapi.ts"; |
| 4 | 4 | |
| 5 | 5 | test("GET /api/v1/passport/health is described, with its passport and its one refusal", () => { |
| 6 | const doc = openapi("https://f.test", "1") as any; | |
| 6 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 7 | 7 | const op = doc.paths["/api/v1/passport/health"]?.get; |
| 8 | 8 | expect(op).toBeDefined(); |
| 9 | 9 | expect(op.summary).toContain("Passport"); |
test/openapi-oa-pull-checks.test.ts +1 −1
| @@ -5,7 +5,7 @@ import { openapi } from "../src/contract/openapi.ts"; | ||
| 5 | 5 | const P = "/api/v1/repos/{owner}/{name}/pulls/{number}/checks"; |
| 6 | 6 | |
| 7 | 7 | test("GET and POST …/pulls/{number}/checks are described", () => { |
| 8 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>; | |
| 8 | const paths = openapi("http://x", "1", "admin").paths as Record<string, Record<string, any>>; | |
| 9 | 9 | expect(paths[P]?.get?.summary).toContain("checks"); |
| 10 | 10 | expect(paths[P]?.post?.responses?.["202"]).toBeDefined(); |
| 11 | 11 | expect(paths[P]?.get?.security).toEqual([]); |
test/openapi-oa-pull-hold.test.ts +1 −1
| @@ -5,7 +5,7 @@ import { openapi } from "../src/contract/openapi.ts"; | ||
| 5 | 5 | const R = "/api/v1/repos/{owner}/{name}/pulls/{number}"; |
| 6 | 6 | |
| 7 | 7 | test("a pull's labels and hold have rows, with the refusals the handlers answer", () => { |
| 8 | const doc = openapi("https://f.test", "1") as any; | |
| 8 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 9 | 9 | expect(doc.paths[`${R}/labels`]?.put, "put labels").toBeDefined(); |
| 10 | 10 | const put = doc.paths[`${R}/hold`]?.put; |
| 11 | 11 | const del = doc.paths[`${R}/hold`]?.delete; |
test/openapi-oa-pull-review.test.ts +1 −1
| @@ -6,7 +6,7 @@ const R = "/api/v1/repos/{owner}/{name}/pulls/{number}"; | ||
| 6 | 6 | type Op = { security?: unknown; responses?: Record<string, unknown>; requestBody?: unknown }; |
| 7 | 7 | |
| 8 | 8 | test("review-states and looks are described with their method, auth and refusals", () => { |
| 9 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, Op>>; | |
| 9 | const paths = openapi("http://x", "1", "admin").paths as Record<string, Record<string, Op>>; | |
| 10 | 10 | expect(paths[`${R}/review-states`]?.get).toBeDefined(); |
| 11 | 11 | const post = paths[`${R}/review-states`]?.post; |
| 12 | 12 | expect(post?.requestBody).toBeDefined(); |
test/openapi-oa-pull-threads.test.ts +2 −2
| @@ -5,7 +5,7 @@ import { openapi } from "../src/contract/openapi.ts"; | ||
| 5 | 5 | const T = "/api/v1/repos/{owner}/{name}/pulls/{number}/threads"; |
| 6 | 6 | |
| 7 | 7 | test("list, resolve and reopen are described", () => { |
| 8 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>; | |
| 8 | const paths = openapi("http://x", "1", "admin").paths as Record<string, Record<string, any>>; | |
| 9 | 9 | expect(paths[T]?.get).toBeDefined(); |
| 10 | 10 | expect(paths[T]?.get.security).toEqual([]); |
| 11 | 11 | expect(paths[`${T}/{anchor}/resolve`]?.post).toBeDefined(); |
| @@ -14,7 +14,7 @@ test("list, resolve and reopen are described", () => { | ||
| 14 | 14 | }); |
| 15 | 15 | |
| 16 | 16 | test("resolve and reopen state their refusals with the statuses the handler answers", () => { |
| 17 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>; | |
| 17 | const paths = openapi("http://x", "1", "admin").paths as Record<string, Record<string, any>>; | |
| 18 | 18 | for (const method of ["post", "delete"]) { |
| 19 | 19 | const text: string = paths[`${T}/{anchor}/resolve`]![method].responses.default.description; |
| 20 | 20 | expect(text).toContain("`not_found` (the pull has no thread at `anchor` (404))"); |
test/openapi-oa-runs.test.ts +1 −1
| @@ -5,7 +5,7 @@ import { openapi } from "../src/contract/openapi.ts"; | ||
| 5 | 5 | const R = "/api/v1/repos/{owner}/{name}"; |
| 6 | 6 | |
| 7 | 7 | test("runs, log, cancel, usage and spend are in the served document", () => { |
| 8 | const doc = openapi("https://f.test", "1") as any; | |
| 8 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 9 | 9 | const want: [string, string][] = [ |
| 10 | 10 | ["get", `${R}/runs`], |
| 11 | 11 | ["get", `${R}/runs/{id}`], |
test/openapi-oa-sec.test.ts +2 −2
| @@ -17,12 +17,12 @@ const WANT: readonly (readonly [string, string])[] = [ | ||
| 17 | 17 | ]; |
| 18 | 18 | |
| 19 | 19 | test("each secrets, token, session and telemetry route is in the document", () => { |
| 20 | const doc = openapi("https://f.test", "1") as any; | |
| 20 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 21 | 21 | for (const [path, method] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); |
| 22 | 22 | }); |
| 23 | 23 | |
| 24 | 24 | test("token routes say a bearer token is refused, secrets that no value is read back", () => { |
| 25 | const doc = openapi("https://f.test", "1") as any; | |
| 25 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 26 | 26 | for (const [p, m] of [["/api/v1/tokens", "get"], ["/api/v1/tokens", "post"], ["/api/v1/tokens/{id}", "delete"]] as const) |
| 27 | 27 | expect(doc.paths[p][m].summary).toContain("bearer token is refused"); |
| 28 | 28 | expect(doc.paths["/api/v1/secrets/{scope}"].get.summary).toContain("never a value"); |
test/openapi-oa-waves.test.ts +1 −1
| @@ -5,7 +5,7 @@ import { openapi } from "../src/contract/openapi.ts"; | ||
| 5 | 5 | const R = "/api/v1/repos/{owner}/{name}"; |
| 6 | 6 | |
| 7 | 7 | test("the waves list and one wave are in the served document with their 404", () => { |
| 8 | const doc = openapi("https://f.test", "1") as any; | |
| 8 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 9 | 9 | const list = doc.paths[`${R}/waves`]?.get; |
| 10 | 10 | const one = doc.paths[`${R}/waves/{version}`]?.get; |
| 11 | 11 | expect(list).toBeDefined(); |
test/openapi-pulls.test.ts +1 −1
| @@ -84,7 +84,7 @@ const call = (method: string, path: string, body?: unknown) => | ||
| 84 | 84 | ); |
| 85 | 85 | |
| 86 | 86 | test("each path and method is in the document", () => { |
| 87 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, unknown>>; | |
| 87 | const paths = openapi("http://x", "1", "admin").paths as Record<string, Record<string, unknown>>; | |
| 88 | 88 | for (const [method, path] of WANT) expect(paths[path]?.[method]).toBeDefined(); |
| 89 | 89 | }); |
| 90 | 90 |
test/openapi-repo.test.ts +2 −2
| @@ -25,7 +25,7 @@ const WANT: readonly (readonly [string, string])[] = [ | ||
| 25 | 25 | ]; |
| 26 | 26 | |
| 27 | 27 | test("each repository path and method is in the document", () => { |
| 28 | const doc = openapi("https://f.test", "1") as any; | |
| 28 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 29 | 29 | for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); |
| 30 | 30 | expect(doc.paths["/api/v1/repos"].post.responses["201"]).toBeDefined(); |
| 31 | 31 | }); |
| @@ -73,7 +73,7 @@ async function call(method: string, path: string, body: unknown): Promise<{ stat | ||
| 73 | 73 | } |
| 74 | 74 | |
| 75 | 75 | function documented(method: string, path: string, code: string): void { |
| 76 | const doc = openapi("https://f.test", "1") as any; | |
| 76 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 77 | 77 | expect(doc.paths[path][method].responses.default.description).toContain(`\`${code}\``); |
| 78 | 78 | } |
| 79 | 79 |
test/openapi-selfopen.test.ts +3 −2
| @@ -8,6 +8,7 @@ import { openapi } from "../src/contract/openapi.ts"; | ||
| 8 | 8 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | 9 | import { seedAdmin } from "./helpers/admin.ts"; |
| 10 | 10 | import { tokenFor } from "./helpers/tokens.ts"; |
| 11 | import { adminOpenapi } from "./helpers/openapi-doc.ts"; | |
| 11 | 12 | |
| 12 | 13 | let dataDir: string; |
| 13 | 14 | let app: App; |
| @@ -47,13 +48,13 @@ const refusal = async (res: Response) => ((await res.json()) as { error: string | ||
| 47 | 48 | |
| 48 | 49 | /** The codes the document names for a row. */ |
| 49 | 50 | function codes(path: string, method: string): string[] { |
| 50 | const doc = openapi("http://x", "1") as any; | |
| 51 | const doc = openapi("http://x", "1", "admin") as any; | |
| 51 | 52 | const text: string = doc.paths[path][method].responses.default.description; |
| 52 | 53 | return [...text.matchAll(/`(\w+)` \(/g)].map((m) => m[1]!); |
| 53 | 54 | } |
| 54 | 55 | |
| 55 | 56 | test("each path and method is in the served document", async () => { |
| 56 | const res = await fetch(`${servers.url}/contract/openapi.json`); | |
| 57 | const res = await adminOpenapi(app, servers.url, "/contract/openapi.json"); | |
| 57 | 58 | const paths = ((await res.json()) as any).paths; |
| 58 | 59 | for (const [method, path] of WANT) expect(paths[path]?.[method]).toBeDefined(); |
| 59 | 60 | expect(paths["/api/v1/openapi.json"].get.responses["308"]).toBeDefined(); |
test/openapi.test.ts +33 −33
| @@ -20,7 +20,7 @@ test("every http route the manifest names is described", () => { | ||
| 20 | 20 | }), |
| 21 | 21 | ); |
| 22 | 22 | expect(named.length).toBeGreaterThan(5); |
| 23 | const doc = openapi("https://f.test", "1") as any; | |
| 23 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 24 | 24 | // The sign-up and sign-in pages are HTML for a person, not part of the API. |
| 25 | 25 | const pages = new Set(["/signup", "/signin"]); |
| 26 | 26 | for (const [method, path] of named) { |
| @@ -57,7 +57,7 @@ test("every described route is registered in the router", () => { | ||
| 57 | 57 | }); |
| 58 | 58 | |
| 59 | 59 | test("the map, plan and dependency routes are described", () => { |
| 60 | const doc = openapi("https://f.test", "1") as any; | |
| 60 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 61 | 61 | const R = "/api/v1/repos/{owner}/{name}"; |
| 62 | 62 | expect(doc.paths[`${R}/map`].get.security).toEqual([]); |
| 63 | 63 | expect(doc.paths[`${R}/plan`].post.requestBody.content["application/json"].schema.properties.issues.type).toBe("array"); |
| @@ -67,7 +67,7 @@ test("the map, plan and dependency routes are described", () => { | ||
| 67 | 67 | }); |
| 68 | 68 | |
| 69 | 69 | test("starting a migration and reading its job are described", () => { |
| 70 | const doc = openapi("https://f.test", "1") as any; | |
| 70 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 71 | 71 | const start = doc.paths["/api/v1/migrate"].post; |
| 72 | 72 | expect(start.security).toBeDefined(); |
| 73 | 73 | expect(Object.keys(start.requestBody.content["application/json"].schema.properties)).toEqual( |
| @@ -79,7 +79,7 @@ test("starting a migration and reading its job are described", () => { | ||
| 79 | 79 | }); |
| 80 | 80 | |
| 81 | 81 | test("listing issues and pulls is described with the query the handlers read", () => { |
| 82 | const doc = openapi("https://f.test", "1") as any; | |
| 82 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 83 | 83 | const R = "/api/v1/repos/{owner}/{name}"; |
| 84 | 84 | for (const kind of ["issues", "pulls"]) { |
| 85 | 85 | const get = doc.paths[`${R}/${kind}`].get; |
| @@ -95,7 +95,7 @@ test("listing issues and pulls is described with the query the handlers read", ( | ||
| 95 | 95 | }); |
| 96 | 96 | |
| 97 | 97 | test("reading one pull describes its diff switch", () => { |
| 98 | const doc = openapi("https://f.test", "1") as any; | |
| 98 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 99 | 99 | const get = doc.paths["/api/v1/repos/{owner}/{name}/pulls/{number}"].get; |
| 100 | 100 | const diff = get.parameters.find((p: any) => p.in === "query" && p.name === "diff"); |
| 101 | 101 | expect(diff.schema.enum).toEqual(["0", "1"]); |
| @@ -103,7 +103,7 @@ test("reading one pull describes its diff switch", () => { | ||
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | 105 | test("reading and closing one issue and reading one pull are described", () => { |
| 106 | const doc = openapi("https://f.test", "1") as any; | |
| 106 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 107 | 107 | const R = "/api/v1/repos/{owner}/{name}"; |
| 108 | 108 | const issue = doc.paths[`${R}/issues/{number}`]; |
| 109 | 109 | expect(issue.get.security).toEqual([]); |
| @@ -116,7 +116,7 @@ test("reading and closing one issue and reading one pull are described", () => { | ||
| 116 | 116 | }); |
| 117 | 117 | |
| 118 | 118 | test("making and listing apps is described with every field the route reads", () => { |
| 119 | const doc = openapi("https://f.test", "1") as any; | |
| 119 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 120 | 120 | expect(doc.paths["/api/v1/apps"].get.security).toEqual([]); |
| 121 | 121 | const post = doc.paths["/api/v1/apps"].post; |
| 122 | 122 | expect(post.security).toBeDefined(); |
| @@ -136,7 +136,7 @@ test("making and listing apps is described with every field the route reads", () | ||
| 136 | 136 | }); |
| 137 | 137 | |
| 138 | 138 | test("the caller's own routes are described, pinning included", () => { |
| 139 | const doc = openapi("https://f.test", "1") as any; | |
| 139 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 140 | 140 | const want: Record<string, string[]> = { |
| 141 | 141 | "/api/v1/user": ["get", "patch"], |
| 142 | 142 | "/api/v1/user/keys": ["get", "post"], |
| @@ -174,7 +174,7 @@ test("the caller's own routes are described, pinning included", () => { | ||
| 174 | 174 | }); |
| 175 | 175 | |
| 176 | 176 | test("the boards routes are described", () => { |
| 177 | const doc = openapi("https://f.test", "1") as any; | |
| 177 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 178 | 178 | const B = "/api/v1/boards"; |
| 179 | 179 | const want: [string, string][] = [ |
| 180 | 180 | ["get", B], ["get", `${B}/{slug}`], ["put", `${B}/{slug}`], ["delete", `${B}/{slug}`], |
| @@ -188,7 +188,7 @@ test("the boards routes are described", () => { | ||
| 188 | 188 | }); |
| 189 | 189 | |
| 190 | 190 | test("a repository's reads are described: branches, commits, contents, labels, milestones", () => { |
| 191 | const doc = openapi("https://f.test", "1") as any; | |
| 191 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 192 | 192 | const R = "/api/v1/repos/{owner}/{name}"; |
| 193 | 193 | for (const path of ["branches", "commits", "commits/{oid}", "contents", "labels", "milestones", "milestones/{number}"]) |
| 194 | 194 | expect(doc.paths[`${R}/${path}`]?.get, path).toBeDefined(); |
| @@ -199,7 +199,7 @@ test("a repository's reads are described: branches, commits, contents, labels, m | ||
| 199 | 199 | }); |
| 200 | 200 | |
| 201 | 201 | test("an issue's teams and labels, a pull's labels and an issue's private repository are described", () => { |
| 202 | const doc = openapi("https://f.test", "1") as any; | |
| 202 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 203 | 203 | const R = "/api/v1/repos/{owner}/{name}"; |
| 204 | 204 | for (const [method, path] of [ |
| 205 | 205 | ["get", `${R}/issues/{number}/teams`], |
| @@ -224,7 +224,7 @@ test("no method and path is described twice, so no row is dead", () => { | ||
| 224 | 224 | }); |
| 225 | 225 | |
| 226 | 226 | test("the front reads say what they answer and refuse", () => { |
| 227 | const doc = openapi("https://f.test", "1") as any; | |
| 227 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 228 | 228 | expect(JSON.stringify(doc.paths["/api/v1/here"].get)).toContain("not_found"); |
| 229 | 229 | expect(JSON.stringify(doc.paths["/api/v1/forge"].get)).toContain("not_found"); |
| 230 | 230 | expect(doc.paths["/api/v1/forge"].get.summary).toContain("lastActivityAt"); |
| @@ -233,7 +233,7 @@ test("the front reads say what they answer and refuse", () => { | ||
| 233 | 233 | }); |
| 234 | 234 | |
| 235 | 235 | test("signing up, signing in and the first admin are described", () => { |
| 236 | const doc = openapi("https://f.test", "1") as any; | |
| 236 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 237 | 237 | const want: [string, string, number][] = [ |
| 238 | 238 | ["post", "/api/v1/users", 201], |
| 239 | 239 | ["post", "/api/v1/sessions", 201], |
| @@ -273,7 +273,7 @@ test("the served document carries the sign-up and sign-in routes", async () => { | ||
| 273 | 273 | }); |
| 274 | 274 | |
| 275 | 275 | test("an app's routes are described, and env values are never read back", () => { |
| 276 | const doc = openapi("https://f.test", "1") as any; | |
| 276 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 277 | 277 | const A = "/api/v1/repos/{owner}/{name}/app"; |
| 278 | 278 | const want: Record<string, string[]> = { |
| 279 | 279 | [A]: ["get", "post", "patch", "delete"], |
| @@ -296,7 +296,7 @@ test("an app's routes are described, and env values are never read back", () => | ||
| 296 | 296 | }); |
| 297 | 297 | |
| 298 | 298 | test("triage, confirming a wait, a limb and proposal tokens are described", () => { |
| 299 | const doc = openapi("https://f.test", "1") as any; | |
| 299 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 300 | 300 | const R = "/api/v1/repos/{owner}/{name}"; |
| 301 | 301 | for (const [path, status] of [ |
| 302 | 302 | [`${R}/reports/{id}/accept`, "201"], |
| @@ -317,7 +317,7 @@ test("triage, confirming a wait, a limb and proposal tokens are described", () = | ||
| 317 | 317 | }); |
| 318 | 318 | |
| 319 | 319 | test("browsing an origin's code is described, and a refusal matches what the route answers", async () => { |
| 320 | const doc = openapi("https://f.test", "1") as any; | |
| 320 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 321 | 321 | const B = "/api/v1/user/origins/{id}/browse/{owner}/{name}"; |
| 322 | 322 | for (const tail of ["branches", "tags", "labels", "paths/{ref}", "tree/{ref}", "tree/{ref}/{path}", "blob/{ref}/{path}", "commits", "commits/{sha}"]) { |
| 323 | 323 | const op = doc.paths[`${B}/${tail}`]?.get; |
| @@ -350,7 +350,7 @@ test("browsing an origin's code is described, and a refusal matches what the rou | ||
| 350 | 350 | }); |
| 351 | 351 | |
| 352 | 352 | test("mirroring and forking from an origin, its job and stream, and origin apps are described", () => { |
| 353 | const doc = openapi("https://f.test", "1") as any; | |
| 353 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 354 | 354 | const want: [string, string][] = [ |
| 355 | 355 | ["/api/v1/user/origins/{id}/mirror", "post"], |
| 356 | 356 | ["/api/v1/user/origins/{id}/fork", "post"], |
| @@ -371,7 +371,7 @@ test("mirroring and forking from an origin, its job and stream, and origin apps | ||
| 371 | 371 | }); |
| 372 | 372 | |
| 373 | 373 | test("the caller's origins are described, from listing to removal, with no token in the document", () => { |
| 374 | const doc = openapi("https://f.test", "1") as any; | |
| 374 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 375 | 375 | const O = "/api/v1/user/origins"; |
| 376 | 376 | const want: Record<string, string[]> = { |
| 377 | 377 | [O]: ["get", "post"], |
| @@ -389,7 +389,7 @@ test("the caller's origins are described, from listing to removal, with no token | ||
| 389 | 389 | }); |
| 390 | 390 | |
| 391 | 391 | test("the admin's writes are described: a capacity setting, a limit and a team", () => { |
| 392 | const doc = openapi("https://f.test", "1") as any; | |
| 392 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 393 | 393 | for (const path of ["capacity/{key}", "limits/{key}", "teams/{slug}"]) { |
| 394 | 394 | const put = doc.paths[`/api/v1/admin/${path}`]?.put; |
| 395 | 395 | expect(put, path).toBeDefined(); |
| @@ -401,7 +401,7 @@ test("the admin's writes are described: a capacity setting, a limit and a team", | ||
| 401 | 401 | }); |
| 402 | 402 | |
| 403 | 403 | test("a pull comment, a pull variant, a steered run and a prompt run are described", () => { |
| 404 | const doc = openapi("https://f.test", "1") as any; | |
| 404 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 405 | 405 | const R = "/api/v1/repos/{owner}/{name}"; |
| 406 | 406 | for (const path of ["pulls/{number}/comments", "pulls/{number}/variant", "runs/{id}/steer", "prompts/{id}/run"]) { |
| 407 | 407 | expect(doc.paths[`${R}/${path}`]?.post?.security, path).toBeDefined(); |
| @@ -413,7 +413,7 @@ test("a pull comment, a pull variant, a steered run and a prompt run are describ | ||
| 413 | 413 | }); |
| 414 | 414 | |
| 415 | 415 | test("a person's sign-ins and ending one are described, with no session secret", () => { |
| 416 | const doc = openapi("https://f.test", "1") as any; | |
| 416 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 417 | 417 | const list = doc.paths["/api/v1/users/{username}/signins"]?.get; |
| 418 | 418 | const end = doc.paths["/api/v1/users/{username}/sessions/{id}"]?.delete; |
| 419 | 419 | expect(list).toBeDefined(); |
| @@ -427,7 +427,7 @@ test("a person's sign-ins and ending one are described, with no session secret", | ||
| 427 | 427 | }); |
| 428 | 428 | |
| 429 | 429 | test("a repository's code writes are described: branches and contents", () => { |
| 430 | const doc = openapi("https://f.test", "1") as any; | |
| 430 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 431 | 431 | const R = "/api/v1/repos/{owner}/{name}"; |
| 432 | 432 | expect(doc.paths[`${R}/branches`]?.post).toBeDefined(); |
| 433 | 433 | expect(doc.paths[`${R}/branches/{branch}`]?.delete).toBeDefined(); |
| @@ -437,7 +437,7 @@ test("a repository's code writes are described: branches and contents", () => { | ||
| 437 | 437 | }); |
| 438 | 438 | |
| 439 | 439 | test("the contents row names the refusals of its own handler, protection and size caps included", () => { |
| 440 | const doc = openapi("https://f.test", "1") as any; | |
| 440 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 441 | 441 | const R = "/api/v1/repos/{owner}/{name}"; |
| 442 | 442 | const text = JSON.stringify(doc.paths[`${R}/contents`].post.responses); |
| 443 | 443 | expect(text).toContain("path_conflict"); |
| @@ -446,7 +446,7 @@ test("the contents row names the refusals of its own handler, protection and siz | ||
| 446 | 446 | }); |
| 447 | 447 | |
| 448 | 448 | test("the tell row names its body, its answer and the refusals of its handler", () => { |
| 449 | const doc = openapi("https://f.test", "1") as any; | |
| 449 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 450 | 450 | const op = doc.paths["/api/v1/feed/tells"]?.post; |
| 451 | 451 | expect(op).toBeDefined(); |
| 452 | 452 | expect(op.responses["201"]).toBeDefined(); |
| @@ -456,7 +456,7 @@ test("the tell row names its body, its answer and the refusals of its handler", | ||
| 456 | 456 | }); |
| 457 | 457 | |
| 458 | 458 | test("the origin applications are described, and a client secret is written, never read back", () => { |
| 459 | const doc = openapi("https://f.test", "1") as any; | |
| 459 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 460 | 460 | const list = doc.paths["/api/v1/user/origin-apps"]; |
| 461 | 461 | const add = list.post; |
| 462 | 462 | expect(add.requestBody).toBeDefined(); |
| @@ -473,7 +473,7 @@ test("the origin applications are described, and a client secret is written, nev | ||
| 473 | 473 | }); |
| 474 | 474 | |
| 475 | 475 | test("labels made and removed, a commit's check and a file read by path are described", () => { |
| 476 | const doc = openapi("https://f.test", "1") as any; | |
| 476 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 477 | 477 | const R = "/api/v1/repos/{owner}/{name}"; |
| 478 | 478 | const make = doc.paths[`${R}/labels`].post; |
| 479 | 479 | expect(make.security).toBeDefined(); |
| @@ -491,7 +491,7 @@ test("labels made and removed, a commit's check and a file read by path are desc | ||
| 491 | 491 | }); |
| 492 | 492 | |
| 493 | 493 | test("the feed, chips and asks routes are described, and the bulk default is a preview", () => { |
| 494 | const doc = openapi("https://f.test", "1") as any; | |
| 494 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 495 | 495 | const want: Record<string, string[]> = { |
| 496 | 496 | "/api/v1/feed": ["get"], |
| 497 | 497 | "/api/v1/feed/chips": ["get", "post"], |
| @@ -517,7 +517,7 @@ test("the feed, chips and asks routes are described, and the bulk default is a p | ||
| 517 | 517 | |
| 518 | 518 | test("every feed and asks route src/http/feed.ts registers is described", () => { |
| 519 | 519 | const src = readFileSync("src/http/feed.ts", "utf8"); |
| 520 | const doc = openapi("https://f.test", "1") as any; | |
| 520 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 521 | 521 | const registered = [...src.matchAll(/router\.add\("(\w+)",\s*"([^"]+)"/g)].filter((m) => /^\/api\/v1\/(feed|asks)\b/.test(m[2]!)) |
| 522 | 522 | .map((m) => [m[1]!.toLowerCase(), m[2]!.replace(/:(\w+)/g, "{$1}")] as const); |
| 523 | 523 | expect(registered.length).toBeGreaterThan(8); |
| @@ -527,11 +527,11 @@ test("every feed and asks route src/http/feed.ts registers is described", () => | ||
| 527 | 527 | test("the served document is tagged by version and origin and answers 304 to its tag", async () => { |
| 528 | 528 | const { openapiText } = await import("../src/contract/served.ts"); |
| 529 | 529 | const { weakEtag } = await import("../src/http/etag.ts"); |
| 530 | const a = openapiText("https://a.example", "1"); | |
| 531 | expect(openapiText("https://a.example", "1")).toBe(a); | |
| 530 | const a = openapiText("https://a.example", "1", "admin"); | |
| 531 | expect(openapiText("https://a.example", "1", "admin")).toBe(a); | |
| 532 | 532 | expect(a.etag).toBe(weakEtag(a.text)); |
| 533 | expect(openapiText("https://a.example", "2").etag).not.toBe(a.etag); | |
| 534 | expect(openapiText("https://b.example", "1").etag).not.toBe(a.etag); | |
| 533 | expect(openapiText("https://a.example", "2", "admin").etag).not.toBe(a.etag); | |
| 534 | expect(openapiText("https://b.example", "1", "admin").etag).not.toBe(a.etag); | |
| 535 | 535 | |
| 536 | 536 | const dir = await mkdtemp(join(tmpdir(), "forge-openapi-")); |
| 537 | 537 | const app = createApp({ dataDir: dir, port: 0 }); |
| @@ -552,7 +552,7 @@ test("the served document is tagged by version and origin and answers 304 to its | ||
| 552 | 552 | }); |
| 553 | 553 | |
| 554 | 554 | test("people, organizations and agents are described", () => { |
| 555 | const doc = openapi("https://f.test", "1") as any; | |
| 555 | const doc = openapi("https://f.test", "1", "admin") as any; | |
| 556 | 556 | for (const path of [ |
| 557 | 557 | "/api/v1/users/{username}", |
| 558 | 558 | "/api/v1/users/{username}/repos", |
Default role
adminfails open. Makerolerequired so every caller must decide; src/web/pages/api.ts:32 and src/tui/screens/api.ts:97 currently get everything.