feat(api): publish validated OpenAPI contract
This commit is contained in:
@@ -15,6 +15,8 @@ const contentSecurityPolicy = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// Keep the canonical source available in standalone/container output as well.
|
||||
outputFileTracingIncludes: { "/openapi.yaml": ["../../openapi.yaml"] },
|
||||
poweredByHeader: false,
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"openapi:validate": "vitest run src/lib/openapi.test.ts",
|
||||
"openapi:standalone": "OPENAPI_STANDALONE_TEST=1 vitest run src/test/openapi-standalone.test.ts",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -30,6 +32,7 @@
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.1.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
@@ -38,11 +41,14 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.3.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.0",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(() => { auth.session.mockReset(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
|
||||
|
||||
import { GET as get } from "./route";
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { GET } from "./route";
|
||||
async function get(request: Request) {
|
||||
const response = await GET(request);
|
||||
await assertResponse("/api/admin/whoami", "get", response);
|
||||
return response;
|
||||
}
|
||||
function request(authorization?: string) {
|
||||
return new Request("https://portal.example/api/admin/whoami", { headers: authorization === undefined ? {} : { authorization } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
import * as list from "./route";
|
||||
import * as detail from "./[id]/route";
|
||||
import * as messages from "./[id]/messages/route";
|
||||
const guild = "100000000000000001", forum = "100000000000000002", id = "100000000000000009";
|
||||
const thread = { id, parent_id: forum, guild_id: guild, type: 11, name: "A garden", owner_id: "100000000000000003", applied_tags: [], message_count: 2, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-09-10T00:00:00.123456+00:00" } };
|
||||
const message = { id, content: "", timestamp: "2026-09-10T00:00:00Z", edited_timestamp: null, author: { id: "100000000000000003", username: "Example" }, reactions: [{ emoji: { name: "👍" }, count: 1 }] };
|
||||
const context = { params: Promise.resolve({ id }) };
|
||||
const fetcher = vi.fn();
|
||||
let generation = 0;
|
||||
beforeEach(() => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_BOT_TOKEN", `synthetic-fixture-${++generation}`);
|
||||
vi.stubEnv("DISCORD_GUILD_ID", guild);
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forum);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
fetcher.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] });
|
||||
if (url.includes("/threads/archived/public")) return Response.json({ threads: [thread], has_more: true });
|
||||
if (url.includes("/threads/active")) return Response.json({ threads: [thread] });
|
||||
if (url.endsWith(`/channels/${id}`)) return Response.json(thread);
|
||||
if (url.endsWith(`/messages/${id}`)) return Response.json(message);
|
||||
if (url.includes("/messages?")) return Response.json([message]);
|
||||
throw new Error("Unexpected fixture request");
|
||||
});
|
||||
});
|
||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
const routes = [
|
||||
{ path: "/api/suggestions", route: list },
|
||||
{ path: "/api/suggestions/{id}", route: detail },
|
||||
{ path: "/api/suggestions/{id}/messages", route: messages },
|
||||
];
|
||||
it.each(routes)("validates populated $path response against its canonical schema", async ({ path, route }) => {
|
||||
const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}?limit=1`), context);
|
||||
expect(response.status).toBe(200);
|
||||
await assertResponse(path, "get", response);
|
||||
});
|
||||
it("documents the nullable deleted starter and precise archived cursor", async () => {
|
||||
fetcher.mockImplementationOnce(async () => Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] }))
|
||||
.mockImplementationOnce(async () => Response.json(thread))
|
||||
.mockImplementationOnce(async () => new Response(null, { status: 404 }));
|
||||
const response = await detail.GET(new Request(`https://portal.example/api/suggestions/${id}`), context);
|
||||
await assertResponse("/api/suggestions/{id}", "get", response);
|
||||
expect((await response.json()).originalPost).toBeNull();
|
||||
const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1"));
|
||||
await assertResponse("/api/suggestions", "get", archived);
|
||||
expect((await archived.json()).nextCursor).toBe("2026-09-10T00:00:00.123456Z");
|
||||
});
|
||||
it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => {
|
||||
fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 }));
|
||||
const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}`), context);
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("retry-after")).toBe("3");
|
||||
await assertResponse(path, "get", response);
|
||||
});
|
||||
it.each(routes)("documents every explicitly rejected write method for $path", async ({ path, route }) => {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
|
||||
const response = await route[method](new Request(`https://portal.example${path.replace("{id}", id)}`, { method }));
|
||||
expect(response.status).toBe(405);
|
||||
await assertResponse(path, method, response);
|
||||
}
|
||||
});
|
||||
@@ -2,9 +2,30 @@ import { afterEach, expect, it, vi } from "vitest";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
import { GET, POST } from "./route";
|
||||
import { GET as detail } from "./[id]/route";
|
||||
import { GET as messages } from "./[id]/messages/route";
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { GET as routeGet, POST as routePost } from "./route";
|
||||
import { GET as routeDetail } from "./[id]/route";
|
||||
import { GET as routeMessages } from "./[id]/messages/route";
|
||||
async function GET(request: Request) {
|
||||
const response = await routeGet(request);
|
||||
await assertResponse("/api/suggestions", "get", response);
|
||||
return response;
|
||||
}
|
||||
async function POST(request: Request) {
|
||||
const response = await routePost(request);
|
||||
await assertResponse("/api/suggestions", "post", response);
|
||||
return response;
|
||||
}
|
||||
async function detail(request: Request, context: Parameters<typeof routeDetail>[1]) {
|
||||
const response = await routeDetail(request, context);
|
||||
await assertResponse("/api/suggestions/{id}", "get", response);
|
||||
return response;
|
||||
}
|
||||
async function messages(request: Request, context: Parameters<typeof routeMessages>[1]) {
|
||||
const response = await routeMessages(request, context);
|
||||
await assertResponse("/api/suggestions/{id}/messages", "get", response);
|
||||
return response;
|
||||
}
|
||||
const context = { params: Promise.resolve({ id: "100000000000000009" }) };
|
||||
|
||||
const request = () => new Request("https://portal.example/api/suggestions");
|
||||
|
||||
@@ -47,7 +47,13 @@ vi.mock("@/lib/ip-intelligence", () => ({
|
||||
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
|
||||
import { POST } from "./route";
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { POST as routePost } from "./route";
|
||||
async function POST(request: Request) {
|
||||
const response = await routePost(request);
|
||||
await assertResponse("/api/velocity/access", "post", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const messages = {
|
||||
registrationMessage: "Register {player} in {group}.",
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GET, POST } from "./route";
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { GET as routeGet, POST as routePost } from "./route";
|
||||
async function GET(request: Request) {
|
||||
const response = routeGet(request);
|
||||
await assertResponse("/api/velocity/access", "get", response);
|
||||
return response;
|
||||
}
|
||||
async function POST(request: Request) {
|
||||
const response = await routePost(request);
|
||||
await assertResponse("/api/velocity/access", "post", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
describe("Velocity access API problems", () => {
|
||||
it("returns RFC 9457 for unsupported methods", async () => {
|
||||
const response = GET(new Request("http://localhost/api/velocity/access"));
|
||||
const response = await GET(new Request("http://localhost/api/velocity/access"));
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("POST");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { hashToken } from "@minecraft-account-manager/auth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -38,7 +39,18 @@ vi.mock("@/lib/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
import { GET as routeGet, POST as routePost } from "./route";
|
||||
|
||||
async function GET(request: Request) {
|
||||
const response = routeGet(request);
|
||||
await assertResponse("/api/velocity/connection", "get", response);
|
||||
return response;
|
||||
}
|
||||
async function POST(request: Request) {
|
||||
const response = await routePost(request);
|
||||
await assertResponse("/api/velocity/connection", "post", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
function validRequest(overrides: Record<string, unknown> = {}) {
|
||||
return new Request("http://localhost/api/velocity/connection", {
|
||||
@@ -64,7 +76,7 @@ describe("Velocity connection reporting endpoint", () => {
|
||||
});
|
||||
|
||||
it("rejects methods other than POST with Problem Details", async () => {
|
||||
const response = GET(new Request("http://localhost/api/velocity/connection"));
|
||||
const response = await GET(new Request("http://localhost/api/velocity/connection"));
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("content-type")).toContain("application/problem+json");
|
||||
expect(response.headers.get("allow")).toBe("POST");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-static";
|
||||
|
||||
/** Build-time snapshot of the single source; no YAML parsing or rewriting. */
|
||||
export async function GET() {
|
||||
const specification = await readFile(resolve(process.cwd(), "../../openapi.yaml"));
|
||||
return new Response(specification, {
|
||||
headers: { "content-type": "application/yaml; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("links the public contract and documents safe client-credentials usage", () => {
|
||||
const read = (path: string) => readFileSync(resolve(process.cwd(), "../..", path), "utf8");
|
||||
expect(read("README.md")).toContain("[OpenAPI 3.1](openapi.yaml)");
|
||||
expect(read("README.md")).toContain("/openapi.yaml");
|
||||
const docs = read("docs/admin-api-authentication.md");
|
||||
expect(docs).toContain("/protocol/openid-connect/token");
|
||||
expect(docs).toContain('"grant_type": "client_credentials"');
|
||||
expect(docs).toContain("getpass.getpass");
|
||||
expect(docs).toContain("No helper is installed");
|
||||
expect(docs).toContain("Do not enable shell tracing");
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { GET } from "../app/openapi.yaml/route";
|
||||
|
||||
it("serves the canonical YAML bytes publicly, without rewriting or authentication", async () => {
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/yaml; charset=utf-8");
|
||||
expect(Buffer.from(await response.arrayBuffer())).toEqual(readFileSync(resolve(process.cwd(), "../../openapi.yaml")));
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, relative } from "node:path";
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import { parse } from "yaml";
|
||||
import { assert, expect, it } from "vitest";
|
||||
import { velocityAccessRequestSchema, velocityConnectionRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import { assertResponse, assertSchema, loadContract } from "@/test/openapi-contract";
|
||||
|
||||
const root = resolve(process.cwd(), "../..");
|
||||
const canonical = resolve(root, "openapi.yaml");
|
||||
it("publishes a valid OpenAPI 3.1 contract covering every application API", async () => {
|
||||
expect(existsSync(canonical), "root openapi.yaml must exist").toBe(true);
|
||||
const doc = parse(readFileSync(canonical, "utf8"));
|
||||
expect(doc.openapi).toBe("3.1.0");
|
||||
await SwaggerParser.validate(structuredClone(doc));
|
||||
const api = resolve(process.cwd(), "src/app/api");
|
||||
const routes = readdirSync(api, { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name === "route.ts")
|
||||
.map((entry) => relative(api, resolve(entry.parentPath, entry.name)).replace(/\/route\.ts$/, "").replace(/\[([^\]]+)\]/g, "{$1}"))
|
||||
.filter((path) => !["route.ts", "{...path}", "auth/{...nextauth}"].includes(path))
|
||||
.map((path) => `/api/${path}`);
|
||||
expect(Object.keys(doc.paths).filter((path) => path.startsWith("/api/")).sort()).toEqual(routes.sort());
|
||||
for (const path of routes) {
|
||||
const source = readFileSync(resolve(api, path.slice(5).replace(/\{([^}]+)\}/g, "[$1]"), "route.ts"), "utf8");
|
||||
const methods = [...source.matchAll(/export (?:async )?(?:function|const) (GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g)].map((match) => match[1]!.toLowerCase());
|
||||
for (const method of methods) expect(doc.paths[path][method], `${method} ${path} must be documented`).toBeDefined();
|
||||
if (methods.includes("get")) expect(doc.paths[path].head).toBeDefined();
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const [path, item] of Object.entries(doc.paths) as [string, Record<string, Record<string, unknown>>][]) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (!["get", "head", "post", "put", "patch", "delete", "options"].includes(method)) continue;
|
||||
expect(operation.summary, `${method} ${path}`).toBeTruthy();
|
||||
expect(operation.description).toBeTruthy();
|
||||
expect(ids.has(operation.operationId as string)).toBe(false);
|
||||
ids.add(operation.operationId as string);
|
||||
expect(operation.operationId).toBeTruthy();
|
||||
if (path.startsWith("/api/suggestions") || path === "/api/admin/whoami") {
|
||||
expect(operation.security).toEqual([{ AdminSession: [] }, { AdminBearer: [] }]);
|
||||
} else if (path.startsWith("/api/velocity") && method === "post") {
|
||||
expect(operation.security).toEqual([{ VelocitySecret: [] }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
it("validates examples as JSON Schema 2020-12 and against real Velocity request parsers", async () => {
|
||||
const doc = await loadContract();
|
||||
for (const item of Object.values(doc.paths)) for (const operation of Object.values(item)) {
|
||||
for (const response of Object.values(operation.responses)) {
|
||||
for (const content of Object.values(response.content ?? {})) {
|
||||
for (const example of Object.values(content.examples ?? {})) assertSchema(content.schema, example.value);
|
||||
}
|
||||
}
|
||||
for (const content of Object.values(operation.requestBody?.content ?? {})) {
|
||||
for (const example of Object.values(content.examples ?? {})) assertSchema(content.schema, example.value);
|
||||
}
|
||||
}
|
||||
for (const [kind, schema] of [["access", velocityAccessRequestSchema], ["connection", velocityConnectionRequestSchema]] as const) {
|
||||
const content = doc.paths[`/api/velocity/${kind}`]?.post?.requestBody?.content["application/json"];
|
||||
assert(content);
|
||||
for (const example of Object.values(content.examples ?? {})) expect(schema.safeParse(example.value).success).toBe(true);
|
||||
}
|
||||
});
|
||||
it("contract checking rejects wrong status, media type and response data", async () => {
|
||||
await expect(assertResponse("/api/admin/whoami", "get", Response.json({ authenticationMethod: "bearer", subject: null, name: null, email: null }, { headers: { "cache-control": "no-store" } }))).rejects.toThrow();
|
||||
await expect(assertResponse("/api/admin/whoami", "get", new Response("{}", { headers: { "cache-control": "no-store" } }))).rejects.toThrow();
|
||||
await expect(assertResponse("/api/admin/whoami", "get", Response.json({}, { status: 418 }))).rejects.toThrow();
|
||||
const wrongStatus = Response.json({ type: "urn:error:unauthorized", title: "Unauthorized", status: 403 }, { status: 401, headers: { "content-type": "application/problem+json", "cache-control": "no-store", "www-authenticate": 'Bearer realm="admin-api"' } });
|
||||
await expect(assertResponse("/api/admin/whoami", "get", wrongStatus)).rejects.toThrow();
|
||||
});
|
||||
it("rejects an invalid OpenAPI document (not just parseable YAML)", async () => {
|
||||
await expect(SwaggerParser.validate({ openapi: "3.1.0", info: { title: "Broken", version: "1" }, paths: { "/broken": { get: { responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Missing" } } } } } } } } } as never)).rejects.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import Ajv2020 from "ajv/dist/2020";
|
||||
import addFormats from "ajv-formats";
|
||||
import { parse } from "yaml";
|
||||
import { assert, expect } from "vitest";
|
||||
|
||||
type Schema = Record<string, unknown>;
|
||||
type ResponseContract = {
|
||||
headers?: Record<string, { schema: Schema; description?: string }>;
|
||||
content?: Record<string, { schema: Schema; examples?: Record<string, { value: unknown }> }>;
|
||||
};
|
||||
type Operation = { security?: Record<string, string[]>[]; responses: Record<string, ResponseContract>; requestBody?: { content: Record<string, { schema: Schema; examples?: Record<string, { value: unknown }> }> } };
|
||||
export type Contract = { paths: Record<string, Record<string, Operation>> };
|
||||
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
||||
addFormats(ajv);
|
||||
let contract: Promise<Contract> | undefined;
|
||||
export function loadContract() {
|
||||
return contract ??= SwaggerParser.dereference(parse(readFileSync(resolve(process.cwd(), "../../openapi.yaml"), "utf8"))).then((doc) => doc as unknown as Contract);
|
||||
}
|
||||
export function assertSchema(schema: Schema, value: unknown) {
|
||||
const validate = ajv.compile(schema);
|
||||
expect(validate(value), JSON.stringify(validate.errors)).toBe(true);
|
||||
}
|
||||
/** Clone preserves the body for existing behavior assertions. Never mock handlers. */
|
||||
export async function assertResponse(path: string, method: string, response: Response) {
|
||||
const doc = await loadContract();
|
||||
const operation = doc.paths[path]?.[method.toLowerCase()];
|
||||
assert(operation, `undocumented operation: ${method} ${path}`);
|
||||
const expected = operation.responses[String(response.status)];
|
||||
assert(expected, `undocumented HTTP ${response.status}: ${method} ${path}`);
|
||||
for (const [name, header] of Object.entries(expected.headers ?? {})) {
|
||||
const value = response.headers.get(name);
|
||||
if (name.toLowerCase() === "retry-after" && value === null) continue;
|
||||
expect(value, `missing ${name}`).not.toBeNull();
|
||||
assertSchema(header.schema, header.schema.type === "integer" ? Number(value) : value);
|
||||
}
|
||||
const body = response.clone();
|
||||
if (!expected.content) {
|
||||
expect(await body.text()).toBe("");
|
||||
return;
|
||||
}
|
||||
const mediaType = response.headers.get("content-type")?.split(";", 1)[0];
|
||||
expect(mediaType).toBeTruthy();
|
||||
const content = expected.content[mediaType!];
|
||||
assert(content, `undocumented Content-Type: ${mediaType}`);
|
||||
const value = await body.json();
|
||||
assertSchema(content.schema, value);
|
||||
if (mediaType === "application/problem+json") expect(value.status).toBe(response.status);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { cp, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { assertResponse } from "./openapi-contract";
|
||||
|
||||
it.runIf(process.env.OPENAPI_STANDALONE_TEST === "1")("serves canonical bytes and implicit HEAD from isolated standalone/container layout", async () => {
|
||||
const source = resolve(process.cwd(), ".next/standalone");
|
||||
const directory = await mkdtemp(resolve(tmpdir(), "portal-openapi-"));
|
||||
const socket = createServer();
|
||||
socket.listen(0, "127.0.0.1");
|
||||
await once(socket, "listening");
|
||||
const port = (socket.address() as { port: number }).port;
|
||||
await new Promise<void>((done) => socket.close(() => done()));
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await cp(source, directory, { recursive: true });
|
||||
// This mirrors the existing Dockerfile: standalone plus the separate static tree.
|
||||
await cp(resolve(process.cwd(), ".next/static"), resolve(directory, "apps/web/.next/static"), { recursive: true });
|
||||
const canonical = await readFile(resolve(process.cwd(), "../../openapi.yaml"));
|
||||
expect(await readFile(resolve(directory, "openapi.yaml"))).toEqual(canonical);
|
||||
child = spawn(process.execPath, ["apps/web/server.js"], {
|
||||
cwd: directory,
|
||||
env: { PATH: process.env.PATH, NODE_ENV: "production", HOSTNAME: "127.0.0.1", PORT: String(port) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const server = child;
|
||||
await new Promise<void>((done, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Standalone startup timed out")), 20_000);
|
||||
server.once("error", (error) => { clearTimeout(timer); reject(error); });
|
||||
server.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Standalone exited: ${code}`)); });
|
||||
server.stdout!.on("data", (chunk: Buffer) => {
|
||||
if (chunk.toString().includes("Ready")) { clearTimeout(timer); done(); }
|
||||
});
|
||||
// Drain stderr, but do not forward arbitrary server output into test logs.
|
||||
server.stderr!.resume();
|
||||
});
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const response = await fetch(`${origin}/openapi.yaml`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/yaml; charset=utf-8");
|
||||
expect(Buffer.from(await response.arrayBuffer())).toEqual(canonical);
|
||||
const head = await fetch(`${origin}/openapi.yaml`, { method: "HEAD" });
|
||||
expect(head.status).toBe(200);
|
||||
expect(await head.text()).toBe("");
|
||||
for (const path of ["/api/admin/whoami", "/api/suggestions", "/api/suggestions/{id}", "/api/suggestions/{id}/messages"]) {
|
||||
const url = `${origin}${path.replace("{id}", "100000000000000009")}`;
|
||||
for (const method of ["GET", "HEAD"]) {
|
||||
const result = await fetch(url, { method, headers: { authorization: "Bearer invalid" } });
|
||||
expect(result.status).toBe(401);
|
||||
await assertResponse(path, method, result);
|
||||
}
|
||||
}
|
||||
for (const kind of ["access", "connection"]) {
|
||||
const path = `/api/velocity/${kind}`;
|
||||
const result = await fetch(origin + path, { method: "HEAD" });
|
||||
expect(result.status).toBe(405);
|
||||
await assertResponse(path, "head", result);
|
||||
}
|
||||
} finally {
|
||||
if (child && child.exitCode === null) {
|
||||
const stopped = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
const timer = setTimeout(() => child?.kill("SIGKILL"), 3000);
|
||||
await stopped;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
Reference in New Issue
Block a user