diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 1dd002703d..3ab78f7d8e 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -20901,11 +20901,25 @@ }, "400": { "description": "Malformed JSON or invalid payload shape" + }, + "401": { + "description": "Missing or invalid ingest credential" + }, + "403": { + "description": "Instance not authenticated" + }, + "413": { + "description": "Batch exceeds the 1 MiB (MAX_ORB_INGEST_BODY_BYTES) body ceiling" } }, "operationId": "postOrbIngest", "tags": [ "ORB" + ], + "security": [ + { + "OrbBearer": [] + } ] } }, @@ -26063,7 +26077,7 @@ } ], "responses": { - "202": { + "200": { "description": "Batch accepted" }, "400": { @@ -26071,6 +26085,9 @@ }, "401": { "description": "Missing or invalid ingest credential" + }, + "413": { + "description": "Batch exceeds the 1 MiB (MAX_ORB_INGEST_BODY_BYTES) body ceiling" } } } diff --git a/src/openapi/internal-and-public-route-specs.ts b/src/openapi/internal-and-public-route-specs.ts index 6c5a60ade4..0bb6295a70 100644 --- a/src/openapi/internal-and-public-route-specs.ts +++ b/src/openapi/internal-and-public-route-specs.ts @@ -303,7 +303,31 @@ const MISC_ROUTES: SpecEntry[] = [ // Its own shared-secret header, like the ORB ingress -- not a LoopOver bearer. auth: "orb", summary: "Ingest an AMS miner telemetry batch", - responses: { 202: { description: "Batch accepted" }, 400: { description: "Malformed batch" }, 401: { description: "Missing or invalid ingest credential" } }, + // 200, not 202: the handler returns 200 on success, and that is the shipped, client-observed status. 413 + // reflects the readOrbIngestBody 1 MiB (MAX_ORB_INGEST_BODY_BYTES) hard body ceiling. + responses: { + 200: { description: "Batch accepted" }, + 400: { description: "Malformed batch" }, + 401: { description: "Missing or invalid ingest credential" }, + 413: { description: "Batch exceeds the 1 MiB (MAX_ORB_INGEST_BODY_BYTES) body ceiling" }, + }, + }, + { + method: "post", + path: "/v1/orb/ingest", + operationId: "postOrbIngest", + tags: ["ORB"], + // Its own ORB_INGEST_TOKEN shared-secret bearer, not a LoopOver API token -- so auth: "orb", which is + // what derives the OrbBearer security stanza (the legacy registerPath block had none at all). + auth: "orb", + summary: "Ingest a batch of Orb events", + responses: { + 200: { description: "Batch accepted; returns { accepted: number }" }, + 400: { description: "Malformed JSON or invalid payload shape" }, + 401: { description: "Missing or invalid ingest credential" }, + 403: { description: "Instance not authenticated" }, + 413: { description: "Batch exceeds the 1 MiB (MAX_ORB_INGEST_BODY_BYTES) body ceiling" }, + }, }, { method: "post", diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 3c12240af0..00bb22fdfb 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1959,17 +1959,6 @@ export function buildOpenApiSpec() { 404: { description: "Public stats disabled (same flag as /v1/public/stats)" }, }, }); - registry.registerPath({ - method: "post", - path: "/v1/orb/ingest", - operationId: "postOrbIngest", - tags: ["ORB"], - summary: "Ingest a batch of Orb events", - responses: { - 200: { description: "Batch accepted; returns { accepted: number }" }, - 400: { description: "Malformed JSON or invalid payload shape" }, - }, - }); registry.registerPath({ method: "get", path: "/v1/auth/github/start", diff --git a/test/unit/openapi-ingest-status-parity.test.ts b/test/unit/openapi-ingest-status-parity.test.ts new file mode 100644 index 0000000000..fa716c04bf --- /dev/null +++ b/test/unit/openapi-ingest-status-parity.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { buildOpenApiSpec } from "../../src/openapi/spec"; +import { MAX_ORB_INGEST_BODY_BYTES } from "../../src/orb/ingest"; +import { createTestEnv } from "../helpers/d1"; + +// #9708: the two telemetry-ingest routes' PUBLISHED operations disagreed with their handlers -- AMS declared a +// 202 it never returns (its real status is 200) and omitted 413; ORB was a legacy registerPath block declaring +// only 200/400 with NO security stanza at all, despite carrying its own ORB_INGEST_TOKEN bearer. This test pins, +// for BOTH routes, that the handler's real 401/413/200 statuses are exactly what the OpenAPI document declares. + +const AUTH = { authorization: "Bearer fleet-secret" }; + +const ROUTES = [ + { + path: "/v1/ams/ingest", + tokenEnv: "AMS_INGEST_TOKEN" as const, + // AMS batch shape (camelCase). + validBody: JSON.stringify({ instanceId: "abc0", events: [{ repoHash: "rhash", prHash: "phash", decision: "merged" }] }), + }, + { + path: "/v1/orb/ingest", + tokenEnv: "ORB_INGEST_TOKEN" as const, + // ORB batch shape (snake_case) -- a first-contact instance ingests successfully (registered=0). + validBody: JSON.stringify({ instance_id: "abc0", events: [{ repo_hash: "rhash", pr_hash: "phash", outcome: "merged", reversal_flag: "none" }] }), + }, +]; + +describe("ingest routes: handler status <-> OpenAPI declaration parity (#9708)", () => { + const app = createApp(); + + for (const route of ROUTES) { + const authedEnv = () => createTestEnv({ [route.tokenEnv]: "fleet-secret" } as Partial); + + it(`${route.path}: 401 with no bearer, 413 when oversized, 200 for a valid authenticated batch`, async () => { + // No bearer -> 401 (the collector fails closed). + const noAuth = await app.request(route.path, { method: "POST", headers: { "content-type": "application/json" }, body: route.validBody }, authedEnv()); + expect(noAuth.status).toBe(401); + + // Over the 1 MiB readOrbIngestBody ceiling -> 413. + const oversized = await app.request(route.path, { method: "POST", headers: AUTH, body: "x".repeat(MAX_ORB_INGEST_BODY_BYTES + 16) }, authedEnv()); + expect(oversized.status).toBe(413); + + // Valid authenticated batch -> 200 (never the AMS 202 the spec used to claim). + const ok = await app.request(route.path, { method: "POST", headers: { "content-type": "application/json", ...AUTH }, body: route.validBody }, authedEnv()); + expect(ok.status).toBe(200); + }); + + it(`${route.path}: the OpenAPI document declares each observed status (200, 401, 413), and never 202`, () => { + const responses = buildOpenApiSpec().paths[route.path]?.post?.responses ?? {}; + const declared = Object.keys(responses); + expect(declared).toEqual(expect.arrayContaining(["200", "401", "413"])); + expect(declared).not.toContain("202"); + }); + } + + it("/v1/orb/ingest carries its OrbBearer security stanza (it had none as a legacy registerPath block)", () => { + const op = buildOpenApiSpec().paths["/v1/orb/ingest"]?.post; + expect(op?.security).toEqual([{ OrbBearer: [] }]); + // 403 (instance_unauthenticated) is a real ORB-only status the AMS route never returns. + expect(Object.keys(op?.responses ?? {})).toContain("403"); + }); +});