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

Merged opened this

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

Merged

scout/forge:scout/11286-the-openapi-description-shows-an-anonymo → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/11286-the-openapi-description-shows-an-anonymo was merged into main as c9b0705.

Review

Sign in to review this change.
46 files changed +337 −128
docs/MCP.md +3 −1
@@ -548,7 +548,9 @@ answers
548548 budget is held by a bounded statement count; the `here` phase is read from
549549 Server-Timing and bounded at the flake floor (`WALL_CLOCK_FLOOR_MS`).
550550 - **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`,
552554 `POST …/issues/{number}/dependencies` and
553555 `DELETE …/issues/{number}/dependencies/{blocker}`. `test/openapi.test.ts` fails
554556 when the manifest names a route it lacks.
src/contract/openapi.ts +20 −5
@@ -11,7 +11,7 @@
1111 * so pulls describing different routes do not collide (#5936).
1212 */
1313
14 import type { QueryType, Route } from "./openapi/shared.ts";
14 import type { QueryType, Role, Route } from "./openapi/shared.ts";
1515 import { OAUTH_SCOPES, type OAuthScope } from "../core/oauth.ts";
1616 import { tagList, tagOf } from "./tags.ts";
1717 import * as each from "./openapi/routes/index.ts";
@@ -47,10 +47,25 @@ const schema = (t: QueryType) =>
4747 ? { type: t.type, ...(t.min === undefined ? {} : { minimum: t.min }), ...(t.max === undefined ? {} : { maximum: t.max }) }
4848 : (t === "object" ? { type: "object" } : t === "array" ? { type: "array", items: { type: "object" } } : { type: t });
4949
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> {
5266 const paths: Record<string, Record<string, unknown>> = {};
53 for (const r of ROUTES) {
67 const rows = routesFor(role);
68 for (const r of rows) {
5469 const refusals = pagesByCursor(r) ? { invalid_query: PAGE_REFUSAL, ...r.refusals } : r.refusals;
5570 const params = [...r.path.matchAll(/\{(\w+)\}/g)].map((m) => ({
5671 name: m[1],
@@ -133,7 +148,7 @@ export function openapi(origin: string, version: string): Record<string, unknown
133148 "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.",
134149 },
135150 servers: [{ url: origin }],
136 tags: tagList(ROUTES.map((r) => tagOf(r.path))),
151 tags: tagList(rows.map((r) => tagOf(r.path))),
137152 // The default: a bearer token. Open routes say `security: []`.
138153 security: [{ token: [] }],
139154 paths,
src/contract/served.ts +35 −18
@@ -1,33 +1,44 @@
11 /**
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.
45 */
56
67 import { encodeOnce, negotiate } from "../http/compress.ts";
78 import { holds, weakEtag } from "../http/etag.ts";
8 import type { Handler } from "../http/context.ts";
9 import type { Auth, Handler } from "../http/context.ts";
910 import type { Router } from "../http/router.ts";
1011 import { running } from "../kernel/version.ts";
1112 import { openapi, ROUTES } from "./openapi.ts";
13 import type { Role } from "./openapi/shared.ts";
1214 import { tagList, tagOf } from "./tags.ts";
1315
1416 const MAX = 8;
1517 /** The tags the document lists. */
1618 export const TAGS: readonly string[] = tagList(ROUTES.map((r) => tagOf(r.path))).map((t) => t.name);
19 const ROLES = 3;
1720 const built = new Map<string, { text: string; etag: string }>();
1821
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
1928 /**
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,
outdated

Default role admin fails open. Make role required so every caller must decide; src/web/pages/api.ts:32 and src/tui/screens/api.ts:97 currently get everything.

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.
2334 */
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}`;
2637 let hit = built.get(key);
2738 if (!hit) {
28 const text = JSON.stringify(openapi(origin, version));
39 const text = JSON.stringify(openapi(origin, version, role));
2940 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);
3142 built.set(key, hit);
3243 }
3344 return hit;
@@ -36,14 +47,14 @@ export function openapiText(origin: string, version: string): { text: string; et
3647 /**
3748 * One tag's slice: the same document with only the paths (and operations)
3849 * 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.
4152 */
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}`;
4455 let hit = built.get(key);
4556 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 }[] };
4758 const paths: typeof doc.paths = {};
4859 for (const [path, ops] of Object.entries(doc.paths)) {
4960 const kept = Object.entries(ops).filter(([, op]) => op.tags.includes(tag));
@@ -51,7 +62,7 @@ export function openapiSliceText(origin: string, version: string, tag: string):
5162 }
5263 const text = JSON.stringify({ ...doc, tags: doc.tags.filter((t) => t.name === tag), paths });
5364 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);
5566 built.set(key, hit);
5667 }
5768 return hit;
@@ -66,8 +77,14 @@ export function openapiRoute(router: Router<Handler>): void {
6677 { status: 400, headers: { "cache-control": "no-store" } },
6778 );
6879 }
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 };
7188 if (holds(ctx.request, hit.etag)) return new Response(null, { status: 304, headers });
7289 const type = { "content-type": "application/json", ...headers };
7390 // Encoded once per build at the slow, small level, not per request.
src/tui/screens/api.ts +4 −2
@@ -7,7 +7,7 @@
77 */
88
99 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";
1111 import { createSession, destroySession } from "../../core/sessions.ts";
1212 import { running } from "../../kernel/version.ts";
1313 import type { Ctx, Screen } from "../app/app.ts";
@@ -94,7 +94,9 @@ function answerScreen(ctx: Ctx, op: Operation, answer: Answer): Screen {
9494 export function apiScreen(f: Session, ctx: Ctx): Screen {
9595 const state = new ListState();
9696 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)));
98100 const shown = (): Operation[] => apiOperations(narrow([{ name: "", operations: all }], filter.query)).slice(0, MAX_SHOWN);
99101 let note = "";
100102 return {
src/web/pages/api.ts +16 −12
@@ -9,7 +9,8 @@ import { knownSpec, refreshSpec } from "../../apps/api_spec.ts";
99 import { appsRuntime } from "../../apps/runtime.ts";
1010 import { appView } from "../../actions/apps.ts";
1111 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";
1314 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1415 import { canEnterApp } from "../../core/repos.ts";
1516 import type { Ctx } from "../../http/context.ts";
@@ -24,17 +25,20 @@ const MAX_QUERY = 100;
2425 const asked = (ctx: Ctx): string => (ctx.url.searchParams.get("q") ?? "").trim().slice(0, MAX_QUERY);
2526 const count = (areas: readonly Area[]): number => areas.reduce((n, a) => n + a.operations.length, 0);
2627
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 */
2832 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);
3640 }
37 return areas;
41 return held.areas;
3842 };
3943 })();
4044
@@ -44,11 +48,11 @@ export function apiPage(ctx: Ctx): Response {
4448 if (prefersJson(ctx.request)) return noRoute(ctx.request.method, ctx.url.pathname);
4549 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4650 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)));
4852 const rendering = performance.now();
4953 const body = html`<div class="page-head"><h1>API</h1></div>
5054 <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.
5256 Try it sends the request as you; paste a token to send it as that token instead.
5357 </p>
5458 ${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";
77 import { createToken } from "../src/core/tokens.ts";
88 import { createUser } from "../src/core/users.ts";
99 import { serve, type Servers } from "../src/http/server.ts";
10 import { adminOpenapi } from "./helpers/openapi-doc.ts";
1011
1112 let dataDir = "";
1213 let app: App;
@@ -79,6 +80,6 @@ test("no token is 401", async () => {
7980 });
8081
8182 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;
8384 expect(doc.paths["/api/v1/admin/drills"].get).toBeDefined();
8485 });
test/api-admin-subscriptions.test.ts +2 −1
@@ -8,6 +8,7 @@ import { addSubscription } from "../src/core/subscriptions.ts";
88 import { createToken } from "../src/core/tokens.ts";
99 import { createUser } from "../src/core/users.ts";
1010 import { serve, type Servers } from "../src/http/server.ts";
11 import { adminOpenapi } from "./helpers/openapi-doc.ts";
1112
1213 const SECRET = "sk-ant-api03-seeded-secret-text-0123456789abcdef";
1314 let dataDir = "";
@@ -74,6 +75,6 @@ test("a member is 403 not_admin, no token is 401", async () => {
7475 });
7576
7677 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;
7879 expect(doc.paths["/api/v1/admin/subscriptions"].get).toBeDefined();
7980 });
test/api-cloud-accounts.test.ts +2 −1
@@ -9,6 +9,7 @@ import { createToken } from "../src/core/tokens.ts";
99 import { createUser, getUserByName } from "../src/core/users.ts";
1010 import { serve, type Servers } from "../src/http/server.ts";
1111 import { quietLogs } from "./helpers/globals.ts";
12 import { adminOpenapi } from "./helpers/openapi-doc.ts";
1213
1314 quietLogs();
1415
@@ -83,7 +84,7 @@ describe("GET /api/v1/cloud/accounts", () => {
8384 });
8485
8586 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();
8788 expect(doc.paths["/api/v1/cloud/accounts"].get).toBeDefined();
8889 });
8990 });
test/api-crew-now.test.ts +2 −1
@@ -18,6 +18,7 @@ import { serve, type Servers } from "../src/http/server.ts";
1818 import { seedAdmin } from "./helpers/admin.ts";
1919 import { quietLogs } from "./helpers/globals.ts";
2020 import { mcpTool } from "./helpers/mcp.ts";
21 import { adminOpenapi } from "./helpers/openapi-doc.ts";
2122
2223 quietLogs();
2324
@@ -132,7 +133,7 @@ describe("the crew's work right now over the API and MCP", () => {
132133 });
133134
134135 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;
136137 expect(doc.paths["/api/v1/crew/now"].get).toBeDefined();
137138 });
138139 });
test/api-data.test.ts +2 −1
@@ -15,6 +15,7 @@ import { createUser } from "../src/core/users.ts";
1515 import { listSources } from "../src/data/sources.ts";
1616 import { serve, type Servers } from "../src/http/server.ts";
1717 import { quietLogs } from "./helpers/globals.ts";
18 import { adminOpenapi } from "./helpers/openapi-doc.ts";
1819
1920 quietLogs();
2021
@@ -157,7 +158,7 @@ test("a cross-origin session post is refused with a 403; the same-origin one run
157158 });
158159
159160 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;
161162 const paths = Object.keys(spec.paths);
162163 for (const p of ["/data", "/data/{env}/{file}/tables", "/data/{env}/{file}/rows", "/data/{env}/{file}/query"])
163164 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";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { useFlagRegistry } from "../src/web/pages/flags.ts";
1313 import { quietLogs } from "./helpers/globals.ts";
14 import { adminOpenapi } from "./helpers/openapi-doc.ts";
1415
1516 quietLogs();
1617
@@ -75,7 +76,7 @@ describe("GET /api/v1/flags", () => {
7576 });
7677
7778 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;
7980 expect(doc.paths["/api/v1/flags"].get).toBeDefined();
8081 });
8182 });
test/api-page-param.test.ts +1 −1
@@ -77,7 +77,7 @@ test("a list that does not page by cursor ignores page", async () => {
7777 });
7878
7979 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> }>> };
8181 let seen = 0;
8282 for (const r of ROUTES) {
8383 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";
77 import { createApp, type App } from "../src/app.ts";
88 import { narrow, reference, runnable } from "../src/contract/reference.ts";
99 import { ROUTES } from "../src/contract/openapi.ts";
10 import { createUser } from "../src/core/users.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { createForgeApp } from "../src/tui/forge.ts";
1313 import { apiScreen, runGet } from "../src/tui/screens/api.ts";
@@ -48,13 +48,26 @@ describe("the API reference", () => {
4848 await rm(dir, { recursive: true, force: true });
4949 });
5050
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 () => {
5263 const res = await get("/api");
5364 expect(res.status).toBe(200);
5465 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));
5871 });
5972
6073 test("the search narrows by a word and says how many matched", async () => {
@@ -112,12 +125,25 @@ describe("the API reference", () => {
112125 tui.start();
113126 tui.ctx.push(apiScreen(session, tui.ctx));
114127 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`);
116130 const answer = await runGet(session, "/api/v1/openapi.json");
117131 expect(answer.status).toBe(200);
118132 expect(answer.body).toContain('"openapi"');
119133 });
120134
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
121147 test("readSpec stops reading a body past the cap instead of buffering it", async () => {
122148 let sent = 0;
123149 const big = Bun.serve({
test/api-user-notify.test.ts +2 −1
@@ -11,6 +11,7 @@ import { createToken } from "../src/core/tokens.ts";
1111 import { createUser } from "../src/core/users.ts";
1212 import { serve, type Servers } from "../src/http/server.ts";
1313 import { quietLogs } from "./helpers/globals.ts";
14 import { adminOpenapi } from "./helpers/openapi-doc.ts";
1415
1516 quietLogs();
1617
@@ -95,6 +96,6 @@ test("refusals: agent, scope, bad URL, bad body, cross-site, anonymous", async (
9596 });
9697
9798 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>> };
99100 expect(Object.keys(doc.paths["/api/v1/user/notify"] ?? {}).sort()).toEqual(["get", "put"]);
100101 });
test/build-pin-refusals.test.ts +2 −1
@@ -14,6 +14,7 @@ import { getRepo } from "../src/core/repos.ts";
1414 import { createToken } from "../src/core/tokens.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
1616 import { serve, type Servers } from "../src/http/server.ts";
17 import { adminOpenapi } from "./helpers/openapi-doc.ts";
1718
1819 let dataDir: string;
1920 let app: App;
@@ -70,7 +71,7 @@ afterAll(async () => {
7071 });
7172
7273 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;
7475 const op = doc.paths["/api/v1/repos/{owner}/{name}/builds"].post;
7576 const refusals = String(op.responses.default.description).split("Refusals: ")[1] ?? "";
7677 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";
44 import { openapiSliceText, TAGS } from "../src/contract/served.ts";
55
66 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;
88
99 test("every operation carries exactly one tag, and every tag is listed with a line", () => {
1010 const listed = new Set(doc.tags.map((t) => t.name));
@@ -25,13 +25,13 @@ test("a path's tag is its resource", () => {
2525 });
2626
2727 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;
2929 expect(slice.info).toEqual(doc.info);
3030 expect(slice.components).toEqual(doc.components);
3131 expect(slice.tags.map((t) => t.name)).toEqual(["pulls"]);
3232 const n = Object.values(slice.paths).flatMap((o) => Object.values(o)).length;
3333 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);
3535 });
3636
3737 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 ()
4848 expect(bad.status).toBe(400);
4949 expect(((await bad.json()) as { message: string }).message).toContain(TAGS.join(", "));
5050 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);
5254 const one = JSON.parse(await (await call("?tag=pulls")).text()) as Doc;
5355 expect(Object.keys(one.paths).length).toBeLessThan(Object.keys(whole.paths).length);
5456 });
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";
3434 import { showBoard } from "../src/actions/boards.ts";
3535 import { panelData } from "../src/boards/panel_data.ts";
3636 import { listVia } from "./mcp-list.ts";
37 import { adminOpenapi } from "./helpers/openapi-doc.ts";
3738
3839 /** tools/list's order: the tools by name, then `report`. */
3940 const sortedThenReport = (names: string[]): string[] => [
@@ -1589,14 +1590,14 @@ describe("the forge over MCP", () => {
15891590 });
15901591
15911592 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");
15931594 expect(res.status).toBe(200);
15941595 const doc = (await res.json()) as any;
15951596 expect(doc.openapi).toBe("3.1.0");
15961597 expect(doc.paths["/mcp"].post).toBeDefined();
15971598 });
15981599 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;
16001601 const R = "/api/v1/repos/{owner}/{name}";
16011602 expect(doc.paths[`${R}/issues/{number}/teams`].get).toBeDefined();
16021603 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";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { serve, type Servers } from "../src/http/server.ts";
8 import { adminOpenapi } from "./helpers/openapi-doc.ts";
89
910 let dataDir: string;
1011 let app: App;
@@ -23,7 +24,7 @@ afterAll(async () => {
2324 });
2425
2526 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");
2728 expect(res.status).toBe(200);
2829 const doc = (await res.json()) as any;
2930 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">> = {
7070 };
7171
7272 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 }>> };
7474 const byKey = new Map(ROUTES.map((r) => [`${r.method.toUpperCase()} ${r.path.replace(/\{owner\}\/\{name\}/, "{owner}/{name}")}`, r]));
7575 for (const [k, role] of Object.entries(PINNED)) {
7676 const r = byKey.get(k);
test/openapi-flow.test.ts +5 −4
@@ -6,6 +6,7 @@ import { join } from "node:path";
66 import { OAUTH_SCOPES } from "../src/core/oauth.ts";
77 import { createApp } from "../src/app.ts";
88 import { serve, type Servers } from "../src/http/server.ts";
9 import { adminOpenapi } from "./helpers/openapi-doc.ts";
910
1011 let dataDir: string;
1112 let servers: Servers;
@@ -23,7 +24,7 @@ afterAll(async () => {
2324 });
2425
2526 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");
2728 expect(res.status).toBe(200);
2829 const doc = (await res.json()) as any;
2930
@@ -40,7 +41,7 @@ test("GET /contract/openapi.json has the flow and the scorecard", async () => {
4041 });
4142
4243 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;
4445 const schemes = doc.components.securitySchemes;
4546 const flow = schemes.oauth2.flows.authorizationCode;
4647 expect(flow.refreshUrl.endsWith("/oauth/token")).toBe(true);
@@ -55,7 +56,7 @@ test("the document describes OAuth: authorization code, refresh, and OpenID disc
5556 });
5657
5758 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;
5960 const op = doc.paths["/api/v1/telemetry/flow"].get;
6061 expect(op.responses["304"]).toBeDefined();
6162 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
6364 });
6465
6566 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;
6768 expect(doc.servers).toEqual([{ url: servers.url }]);
6869 expect(doc.components.securitySchemes.token).toEqual({ type: "http", scheme: "bearer" });
6970 expect(doc.security).toEqual([{ token: [] }]);
test/openapi-limit-bounds.test.ts +2 −1
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { serve, type Servers } from "../src/http/server.ts";
8 import { adminOpenapi } from "./helpers/openapi-doc.ts";
89
910 let dataDir: string;
1011 let app: App;
@@ -16,7 +17,7 @@ beforeAll(async () => {
1617 dataDir = await mkdtemp(join(tmpdir(), "forge-openapi-limit-"));
1718 app = createApp({ dataDir, port: 0 });
1819 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;
2021 });
2122
2223 afterAll(async () => {
test/openapi-milestone-watch.test.ts +1 −1
@@ -5,7 +5,7 @@ import { openapi } from "../src/contract/openapi.ts";
55 const PATH = "/api/v1/repos/{owner}/{name}/milestones/{number}/watch";
66
77 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;
99 for (const m of ["put", "delete"]) {
1010 const op = doc.paths[PATH]?.[m];
1111 expect(op, m).toBeDefined();
test/openapi-milestones-list.test.ts +1 −1
@@ -3,7 +3,7 @@ import { expect, test } from "bun:test";
33 import { openapi } from "../src/contract/openapi.ts";
44
55 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;
77 const op = doc.paths["/api/v1/repos/{owner}/{name}/milestones"]?.get;
88 expect(op).toBeDefined();
99 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])[] = [
2323 ];
2424
2525 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;
2727 for (const [path, m, code] of WANT) {
2828 const op = doc.paths[path]?.[m];
2929 expect(op, `${m} ${path}`).toBeDefined();
test/openapi-oa-acct.test.ts +3 −3
@@ -13,18 +13,18 @@ const WANT: readonly (readonly [string, string])[] = [
1313 ];
1414
1515 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;
1717 for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
1818 });
1919
2020 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;
2222 expect(doc.paths["/api/v1/admin/invites"].post.summary).toContain("shown once");
2323 expect(doc.paths["/api/v1/admin/invites"].post.security).toBeDefined();
2424 });
2525
2626 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;
2828 const password = doc.paths["/api/v1/user/password"]?.post;
2929 const verify = doc.paths["/api/v1/user/email/verify"]?.post;
3030 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])[] = [
2626 ];
2727
2828 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;
3030 for (const [method, path] of want) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
3131 expect(doc.paths["/api/v1/thoughts"].post.security).toBeDefined();
3232 expect(doc.paths["/api/v1/here"].get.security).toEqual([]);
test/openapi-oa-browse.test.ts +1 −1
@@ -29,7 +29,7 @@ afterAll(async () => {
2929 });
3030
3131 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>>;
3333 for (const path of PATHS) {
3434 const get = paths[path]?.get;
3535 expect(get, path).toBeDefined();
test/openapi-oa-crew.test.ts +1 −1
@@ -17,7 +17,7 @@ const WANT: Record<string, string[]> = {
1717 };
1818
1919 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;
2121 for (const [path, methods] of Object.entries(WANT))
2222 for (const m of methods) expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined();
2323 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";
1010 import { openapi } from "../src/contract/openapi.ts";
1111
1212 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;
1414 for (const [path, codes] of [
1515 ["/api/v1/feed/interactions", ["invalid_item", "invalid_kind", "not_found"]],
1616 ["/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])[] = [
2121 ];
2222
2323 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;
2525 for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
2626 });
2727
2828 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;
3030 expect(doc.paths["/api/v1/forges/{name}/claim-link"].post.summary).toContain("shown once");
3131 expect(doc.paths["/api/v1/heartbeat"].post.summary).toContain("child");
3232 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][] = [
2323 ];
2424
2525 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>>;
2727 for (const [method, path] of WANT) expect(paths[path]?.[method]).toBeDefined();
2828 });
test/openapi-oa-passport.test.ts +1 −1
@@ -3,7 +3,7 @@ import { expect, test } from "bun:test";
33 import { openapi } from "../src/contract/openapi.ts";
44
55 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;
77 const op = doc.paths["/api/v1/passport/health"]?.get;
88 expect(op).toBeDefined();
99 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";
55 const P = "/api/v1/repos/{owner}/{name}/pulls/{number}/checks";
66
77 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>>;
99 expect(paths[P]?.get?.summary).toContain("checks");
1010 expect(paths[P]?.post?.responses?.["202"]).toBeDefined();
1111 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";
55 const R = "/api/v1/repos/{owner}/{name}/pulls/{number}";
66
77 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;
99 expect(doc.paths[`${R}/labels`]?.put, "put labels").toBeDefined();
1010 const put = doc.paths[`${R}/hold`]?.put;
1111 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}";
66 type Op = { security?: unknown; responses?: Record<string, unknown>; requestBody?: unknown };
77
88 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>>;
1010 expect(paths[`${R}/review-states`]?.get).toBeDefined();
1111 const post = paths[`${R}/review-states`]?.post;
1212 expect(post?.requestBody).toBeDefined();
test/openapi-oa-pull-threads.test.ts +2 −2
@@ -5,7 +5,7 @@ import { openapi } from "../src/contract/openapi.ts";
55 const T = "/api/v1/repos/{owner}/{name}/pulls/{number}/threads";
66
77 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>>;
99 expect(paths[T]?.get).toBeDefined();
1010 expect(paths[T]?.get.security).toEqual([]);
1111 expect(paths[`${T}/{anchor}/resolve`]?.post).toBeDefined();
@@ -14,7 +14,7 @@ test("list, resolve and reopen are described", () => {
1414 });
1515
1616 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>>;
1818 for (const method of ["post", "delete"]) {
1919 const text: string = paths[`${T}/{anchor}/resolve`]![method].responses.default.description;
2020 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";
55 const R = "/api/v1/repos/{owner}/{name}";
66
77 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;
99 const want: [string, string][] = [
1010 ["get", `${R}/runs`],
1111 ["get", `${R}/runs/{id}`],
test/openapi-oa-sec.test.ts +2 −2
@@ -17,12 +17,12 @@ const WANT: readonly (readonly [string, string])[] = [
1717 ];
1818
1919 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;
2121 for (const [path, method] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
2222 });
2323
2424 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;
2626 for (const [p, m] of [["/api/v1/tokens", "get"], ["/api/v1/tokens", "post"], ["/api/v1/tokens/{id}", "delete"]] as const)
2727 expect(doc.paths[p][m].summary).toContain("bearer token is refused");
2828 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";
55 const R = "/api/v1/repos/{owner}/{name}";
66
77 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;
99 const list = doc.paths[`${R}/waves`]?.get;
1010 const one = doc.paths[`${R}/waves/{version}`]?.get;
1111 expect(list).toBeDefined();
test/openapi-pulls.test.ts +1 −1
@@ -84,7 +84,7 @@ const call = (method: string, path: string, body?: unknown) =>
8484 );
8585
8686 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>>;
8888 for (const [method, path] of WANT) expect(paths[path]?.[method]).toBeDefined();
8989 });
9090
test/openapi-repo.test.ts +2 −2
@@ -25,7 +25,7 @@ const WANT: readonly (readonly [string, string])[] = [
2525 ];
2626
2727 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;
2929 for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
3030 expect(doc.paths["/api/v1/repos"].post.responses["201"]).toBeDefined();
3131 });
@@ -73,7 +73,7 @@ async function call(method: string, path: string, body: unknown): Promise<{ stat
7373 }
7474
7575 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;
7777 expect(doc.paths[path][method].responses.default.description).toContain(`\`${code}\``);
7878 }
7979
test/openapi-selfopen.test.ts +3 −2
@@ -8,6 +8,7 @@ import { openapi } from "../src/contract/openapi.ts";
88 import { serve, type Servers } from "../src/http/server.ts";
99 import { seedAdmin } from "./helpers/admin.ts";
1010 import { tokenFor } from "./helpers/tokens.ts";
11 import { adminOpenapi } from "./helpers/openapi-doc.ts";
1112
1213 let dataDir: string;
1314 let app: App;
@@ -47,13 +48,13 @@ const refusal = async (res: Response) => ((await res.json()) as { error: string
4748
4849 /** The codes the document names for a row. */
4950 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;
5152 const text: string = doc.paths[path][method].responses.default.description;
5253 return [...text.matchAll(/`(\w+)` \(/g)].map((m) => m[1]!);
5354 }
5455
5556 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");
5758 const paths = ((await res.json()) as any).paths;
5859 for (const [method, path] of WANT) expect(paths[path]?.[method]).toBeDefined();
5960 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", () => {
2020 }),
2121 );
2222 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;
2424 // The sign-up and sign-in pages are HTML for a person, not part of the API.
2525 const pages = new Set(["/signup", "/signin"]);
2626 for (const [method, path] of named) {
@@ -57,7 +57,7 @@ test("every described route is registered in the router", () => {
5757 });
5858
5959 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;
6161 const R = "/api/v1/repos/{owner}/{name}";
6262 expect(doc.paths[`${R}/map`].get.security).toEqual([]);
6363 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", () => {
6767 });
6868
6969 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;
7171 const start = doc.paths["/api/v1/migrate"].post;
7272 expect(start.security).toBeDefined();
7373 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", () => {
7979 });
8080
8181 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;
8383 const R = "/api/v1/repos/{owner}/{name}";
8484 for (const kind of ["issues", "pulls"]) {
8585 const get = doc.paths[`${R}/${kind}`].get;
@@ -95,7 +95,7 @@ test("listing issues and pulls is described with the query the handlers read", (
9595 });
9696
9797 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;
9999 const get = doc.paths["/api/v1/repos/{owner}/{name}/pulls/{number}"].get;
100100 const diff = get.parameters.find((p: any) => p.in === "query" && p.name === "diff");
101101 expect(diff.schema.enum).toEqual(["0", "1"]);
@@ -103,7 +103,7 @@ test("reading one pull describes its diff switch", () => {
103103 });
104104
105105 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;
107107 const R = "/api/v1/repos/{owner}/{name}";
108108 const issue = doc.paths[`${R}/issues/{number}`];
109109 expect(issue.get.security).toEqual([]);
@@ -116,7 +116,7 @@ test("reading and closing one issue and reading one pull are described", () => {
116116 });
117117
118118 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;
120120 expect(doc.paths["/api/v1/apps"].get.security).toEqual([]);
121121 const post = doc.paths["/api/v1/apps"].post;
122122 expect(post.security).toBeDefined();
@@ -136,7 +136,7 @@ test("making and listing apps is described with every field the route reads", ()
136136 });
137137
138138 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;
140140 const want: Record<string, string[]> = {
141141 "/api/v1/user": ["get", "patch"],
142142 "/api/v1/user/keys": ["get", "post"],
@@ -174,7 +174,7 @@ test("the caller's own routes are described, pinning included", () => {
174174 });
175175
176176 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;
178178 const B = "/api/v1/boards";
179179 const want: [string, string][] = [
180180 ["get", B], ["get", `${B}/{slug}`], ["put", `${B}/{slug}`], ["delete", `${B}/{slug}`],
@@ -188,7 +188,7 @@ test("the boards routes are described", () => {
188188 });
189189
190190 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;
192192 const R = "/api/v1/repos/{owner}/{name}";
193193 for (const path of ["branches", "commits", "commits/{oid}", "contents", "labels", "milestones", "milestones/{number}"])
194194 expect(doc.paths[`${R}/${path}`]?.get, path).toBeDefined();
@@ -199,7 +199,7 @@ test("a repository's reads are described: branches, commits, contents, labels, m
199199 });
200200
201201 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;
203203 const R = "/api/v1/repos/{owner}/{name}";
204204 for (const [method, path] of [
205205 ["get", `${R}/issues/{number}/teams`],
@@ -224,7 +224,7 @@ test("no method and path is described twice, so no row is dead", () => {
224224 });
225225
226226 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;
228228 expect(JSON.stringify(doc.paths["/api/v1/here"].get)).toContain("not_found");
229229 expect(JSON.stringify(doc.paths["/api/v1/forge"].get)).toContain("not_found");
230230 expect(doc.paths["/api/v1/forge"].get.summary).toContain("lastActivityAt");
@@ -233,7 +233,7 @@ test("the front reads say what they answer and refuse", () => {
233233 });
234234
235235 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;
237237 const want: [string, string, number][] = [
238238 ["post", "/api/v1/users", 201],
239239 ["post", "/api/v1/sessions", 201],
@@ -273,7 +273,7 @@ test("the served document carries the sign-up and sign-in routes", async () => {
273273 });
274274
275275 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;
277277 const A = "/api/v1/repos/{owner}/{name}/app";
278278 const want: Record<string, string[]> = {
279279 [A]: ["get", "post", "patch", "delete"],
@@ -296,7 +296,7 @@ test("an app's routes are described, and env values are never read back", () =>
296296 });
297297
298298 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;
300300 const R = "/api/v1/repos/{owner}/{name}";
301301 for (const [path, status] of [
302302 [`${R}/reports/{id}/accept`, "201"],
@@ -317,7 +317,7 @@ test("triage, confirming a wait, a limb and proposal tokens are described", () =
317317 });
318318
319319 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;
321321 const B = "/api/v1/user/origins/{id}/browse/{owner}/{name}";
322322 for (const tail of ["branches", "tags", "labels", "paths/{ref}", "tree/{ref}", "tree/{ref}/{path}", "blob/{ref}/{path}", "commits", "commits/{sha}"]) {
323323 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
350350 });
351351
352352 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;
354354 const want: [string, string][] = [
355355 ["/api/v1/user/origins/{id}/mirror", "post"],
356356 ["/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
371371 });
372372
373373 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;
375375 const O = "/api/v1/user/origins";
376376 const want: Record<string, string[]> = {
377377 [O]: ["get", "post"],
@@ -389,7 +389,7 @@ test("the caller's origins are described, from listing to removal, with no token
389389 });
390390
391391 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;
393393 for (const path of ["capacity/{key}", "limits/{key}", "teams/{slug}"]) {
394394 const put = doc.paths[`/api/v1/admin/${path}`]?.put;
395395 expect(put, path).toBeDefined();
@@ -401,7 +401,7 @@ test("the admin's writes are described: a capacity setting, a limit and a team",
401401 });
402402
403403 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;
405405 const R = "/api/v1/repos/{owner}/{name}";
406406 for (const path of ["pulls/{number}/comments", "pulls/{number}/variant", "runs/{id}/steer", "prompts/{id}/run"]) {
407407 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
413413 });
414414
415415 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;
417417 const list = doc.paths["/api/v1/users/{username}/signins"]?.get;
418418 const end = doc.paths["/api/v1/users/{username}/sessions/{id}"]?.delete;
419419 expect(list).toBeDefined();
@@ -427,7 +427,7 @@ test("a person's sign-ins and ending one are described, with no session secret",
427427 });
428428
429429 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;
431431 const R = "/api/v1/repos/{owner}/{name}";
432432 expect(doc.paths[`${R}/branches`]?.post).toBeDefined();
433433 expect(doc.paths[`${R}/branches/{branch}`]?.delete).toBeDefined();
@@ -437,7 +437,7 @@ test("a repository's code writes are described: branches and contents", () => {
437437 });
438438
439439 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;
441441 const R = "/api/v1/repos/{owner}/{name}";
442442 const text = JSON.stringify(doc.paths[`${R}/contents`].post.responses);
443443 expect(text).toContain("path_conflict");
@@ -446,7 +446,7 @@ test("the contents row names the refusals of its own handler, protection and siz
446446 });
447447
448448 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;
450450 const op = doc.paths["/api/v1/feed/tells"]?.post;
451451 expect(op).toBeDefined();
452452 expect(op.responses["201"]).toBeDefined();
@@ -456,7 +456,7 @@ test("the tell row names its body, its answer and the refusals of its handler",
456456 });
457457
458458 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;
460460 const list = doc.paths["/api/v1/user/origin-apps"];
461461 const add = list.post;
462462 expect(add.requestBody).toBeDefined();
@@ -473,7 +473,7 @@ test("the origin applications are described, and a client secret is written, nev
473473 });
474474
475475 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;
477477 const R = "/api/v1/repos/{owner}/{name}";
478478 const make = doc.paths[`${R}/labels`].post;
479479 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
491491 });
492492
493493 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;
495495 const want: Record<string, string[]> = {
496496 "/api/v1/feed": ["get"],
497497 "/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
517517
518518 test("every feed and asks route src/http/feed.ts registers is described", () => {
519519 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;
521521 const registered = [...src.matchAll(/router\.add\("(\w+)",\s*"([^"]+)"/g)].filter((m) => /^\/api\/v1\/(feed|asks)\b/.test(m[2]!))
522522 .map((m) => [m[1]!.toLowerCase(), m[2]!.replace(/:(\w+)/g, "{$1}")] as const);
523523 expect(registered.length).toBeGreaterThan(8);
@@ -527,11 +527,11 @@ test("every feed and asks route src/http/feed.ts registers is described", () =>
527527 test("the served document is tagged by version and origin and answers 304 to its tag", async () => {
528528 const { openapiText } = await import("../src/contract/served.ts");
529529 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);
532532 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);
535535
536536 const dir = await mkdtemp(join(tmpdir(), "forge-openapi-"));
537537 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
552552 });
553553
554554 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;
556556 for (const path of [
557557 "/api/v1/users/{username}",
558558 "/api/v1/users/{username}/repos",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs