Skip to content

Commit 5fb3bdb

Browse files
committed
test(rest): pin GET {base}/docs as not served on either route shape, and /openapi.json as kept
The pin is red on the current tree (the viewer page is still registered) and turns green with the retirement in the next commit. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2b6cf2 commit 5fb3bdb

1 file changed

Lines changed: 114 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25104] `GET {base}/docs`, the HTML viewer page for the OpenAPI document, is
5+
* retired: nothing read it, and the API origin serves no HTML viewer. The
6+
* machine-readable document, `GET {base}/openapi.json`, stays.
7+
*
8+
* Pinned on both route shapes the server mounts: the unscoped `/api/v1` and,
9+
* under project scoping, its `/environments/:environmentId` twin.
10+
*
11+
* - NOT SERVED: no `{base}/docs` registration in the route table the server
12+
* reports (`getRoutes()`, which the document's built-in section and the
13+
* route ledger both read), none in the route manager's own lookup, and no
14+
* `/docs` path handed to the host's `IHttpServer` under any verb. A request
15+
* to the retired path therefore meets the host's ordinary not-found answer.
16+
* - NOT DESCRIBED: the served document lists no `/docs` path.
17+
* - THE DOCUMENT STAYS: `{base}/openapi.json` is registered on both shapes
18+
* and serves a signed-in caller the OpenAPI 3.1 body.
19+
*/
20+
21+
import { describe, it, expect, vi } from 'vitest';
22+
import { RestServer } from './rest-server.js';
23+
24+
const UNSCOPED = '/api/v1';
25+
const SCOPED = '/api/v1/environments/:environmentId';
26+
const BASES = [UNSCOPED, SCOPED] as const;
27+
const ENV = 'env_1';
28+
const VERBS = ['get', 'post', 'put', 'delete', 'patch'] as const;
29+
30+
function makeHttpServer() {
31+
return {
32+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
33+
use: vi.fn(), listen: vi.fn(), close: vi.fn(),
34+
} as any;
35+
}
36+
37+
/** A server with both bases mounted and a signed-in caller resolved for every request. */
38+
function makeRest() {
39+
const httpServer = makeHttpServer();
40+
const protocol: any = {
41+
getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })),
42+
};
43+
const rest = new RestServer(
44+
httpServer,
45+
protocol,
46+
{ api: { version: 'v1', enableProjectScoping: true, projectResolution: 'auto' } } as any,
47+
);
48+
(rest as any).resolveExecCtx = vi.fn(async () => ({ userId: 'u_docs_reader' }));
49+
rest.registerRoutes();
50+
return { rest, httpServer };
51+
}
52+
53+
/** Every path handed to the host, under any verb. */
54+
function hostPaths(httpServer: any): string[] {
55+
return VERBS.flatMap((verb) => httpServer[verb].mock.calls.map((call: unknown[]) => String(call[0])));
56+
}
57+
58+
async function serveDocument(rest: RestServer, base: string): Promise<{ status: number; body: any }> {
59+
const path = `${base}/openapi.json`;
60+
const entry = (rest as any).routeManager.get('GET', path);
61+
expect(entry, `GET ${path} must be mounted`).toBeDefined();
62+
const observed = { status: 200, body: undefined as any };
63+
const res: any = {
64+
status: (c: number) => { observed.status = c; return res; },
65+
json: (b: unknown) => { observed.body = b; return res; },
66+
send: () => res,
67+
setHeader: () => res,
68+
header: () => res,
69+
};
70+
const wirePath = path.replace(':environmentId', ENV);
71+
await entry.handler({
72+
method: 'GET',
73+
path: wirePath,
74+
url: wirePath,
75+
params: base === SCOPED ? { environmentId: ENV } : {},
76+
query: {},
77+
headers: { host: 'example.test' },
78+
}, res);
79+
return observed;
80+
}
81+
82+
describe('[#25104] GET {base}/docs is not served, and GET {base}/openapi.json still is', () => {
83+
for (const base of BASES) {
84+
it(`${base}/docs has no registration: not in getRoutes(), not in the route manager, not on the host`, () => {
85+
const { rest, httpServer } = makeRest();
86+
const retired = `${base}/docs`;
87+
expect(rest.getRoutes().filter((r) => r.path === retired)).toEqual([]);
88+
for (const verb of VERBS) {
89+
expect((rest as any).routeManager.get(verb.toUpperCase(), retired), `${verb.toUpperCase()} ${retired}`).toBeUndefined();
90+
}
91+
expect(hostPaths(httpServer).filter((p) => p === retired)).toEqual([]);
92+
});
93+
94+
it(`${base}/openapi.json stays registered, on the server and on the host`, () => {
95+
const { rest, httpServer } = makeRest();
96+
const kept = `${base}/openapi.json`;
97+
expect(rest.getRoutes().filter((r) => r.path === kept).map((r) => r.method.toUpperCase())).toEqual(['GET']);
98+
expect(hostPaths(httpServer).filter((p) => p === kept)).toEqual([kept]);
99+
});
100+
101+
it(`the document served on ${base} is the OpenAPI 3.1 body, and it describes no /docs path`, async () => {
102+
const { rest } = makeRest();
103+
const { status, body } = await serveDocument(rest, base);
104+
expect(status).toBe(200);
105+
expect(body?.openapi).toBe('3.1.0');
106+
const paths = Object.keys(body?.paths ?? {});
107+
const template = base.replace(':environmentId', '{environmentId}');
108+
// CONTROL: the document describes its own route on this base, so the
109+
// absence below is read from a populated section.
110+
expect(paths).toContain(`${template}/openapi.json`);
111+
expect(paths.filter((p) => p.endsWith('/docs'))).toEqual([]);
112+
});
113+
}
114+
});

0 commit comments

Comments
 (0)