feat(api): expose REST-only agent routes
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
import { requireAgentAuth } from "../../src/auth/agentAuth.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("requireAgentAuth", () => {
|
||||
it("preserves dependency outages without exposing upstream response text", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("postgresql://secret-host/internal-table", {
|
||||
status: 503,
|
||||
})) as unknown as typeof fetch;
|
||||
const headers: Record<string, string> = {
|
||||
authorization: "Bearer token",
|
||||
"x-project-id": "project-id",
|
||||
};
|
||||
const req = {
|
||||
header: (name: string) => headers[name.toLowerCase()],
|
||||
} as Request;
|
||||
let status = 200;
|
||||
let body: unknown;
|
||||
const res = {
|
||||
status: (value: number) => {
|
||||
status = value;
|
||||
return res;
|
||||
},
|
||||
json: (value: unknown) => {
|
||||
body = value;
|
||||
return res;
|
||||
},
|
||||
} as unknown as Response;
|
||||
let nextCalled = false;
|
||||
|
||||
await requireAgentAuth(req, res, (() => {
|
||||
nextCalled = true;
|
||||
}) as NextFunction);
|
||||
|
||||
expect(status).toBe(503);
|
||||
expect(body).toEqual({ message: "authentication service unavailable" });
|
||||
expect(JSON.stringify(body)).not.toContain("secret-host");
|
||||
expect(nextCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
||||
|
||||
import {
|
||||
createRouteRegistrar,
|
||||
generateAgentOpenApi,
|
||||
} from "../../src/contracts/openapi.js";
|
||||
|
||||
const KEBAB_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
describe("Agent REST OpenAPI", () => {
|
||||
test("rejects duplicate method and path registrations", () => {
|
||||
const register = createRouteRegistrar(new OpenAPIRegistry());
|
||||
const config = {
|
||||
summary: "Test operation",
|
||||
responses: { 200: { description: "Successful response" } },
|
||||
};
|
||||
|
||||
register("/api/v1/agent/test", "get", config);
|
||||
|
||||
expect(() => register("/api/v1/agent/test", "get", config)).toThrow(
|
||||
"Duplicate Agent OpenAPI operation: GET /api/v1/agent/test",
|
||||
);
|
||||
});
|
||||
|
||||
test("publishes only unique kebab-case public operations", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
const operationIds = new Set<string>();
|
||||
let operationCount = 0;
|
||||
|
||||
for (const [path, pathItem] of Object.entries(document.paths)) {
|
||||
expect(path.endsWith("/")).toBe(false);
|
||||
for (const segment of path.split("/").filter(Boolean)) {
|
||||
if (!segment.startsWith("{")) {
|
||||
expect(segment).toMatch(KEBAB_SEGMENT);
|
||||
}
|
||||
}
|
||||
for (const operation of Object.values(pathItem ?? {})) {
|
||||
if (!operation || typeof operation !== "object" || !("responses" in operation)) {
|
||||
continue;
|
||||
}
|
||||
operationCount += 1;
|
||||
expect(operation.operationId).toBeTruthy();
|
||||
expect(operationIds.has(operation.operationId!)).toBe(false);
|
||||
operationIds.add(operation.operationId!);
|
||||
expect(operation.security).toEqual([{ bearerAuth: [] }]);
|
||||
}
|
||||
}
|
||||
|
||||
expect(operationCount).toBe(13);
|
||||
});
|
||||
|
||||
test("models runs as session subresources", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
expect(document.paths["/api/v1/agent/sessions/{session_id}/runs"]?.post).toBeTruthy();
|
||||
expect(
|
||||
document.paths[
|
||||
"/api/v1/agent/sessions/{session_id}/runs/current/events"
|
||||
]?.get,
|
||||
).toBeTruthy();
|
||||
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("matches the public session runtime response shapes", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
const schemas = document.components?.schemas ?? {};
|
||||
const createResponses =
|
||||
document.paths["/api/v1/agent/sessions"]?.post?.responses ?? {};
|
||||
const patchOperation =
|
||||
document.paths["/api/v1/agent/sessions/{session_id}"]?.patch;
|
||||
const patchRequestBody = patchOperation?.requestBody;
|
||||
const forkResponses =
|
||||
document.paths["/api/v1/agent/sessions/{session_id}/forks"]?.post
|
||||
?.responses ?? {};
|
||||
|
||||
expect(Object.keys(createResponses)).toContain("200");
|
||||
expect(Object.keys(createResponses)).toContain("201");
|
||||
expect(schemas.AgentSessionSummary).toMatchObject({
|
||||
required: expect.arrayContaining(["id", "created_at", "updated_at", "status"]),
|
||||
});
|
||||
expect(schemas.AgentSessionSummary).not.toMatchObject({
|
||||
required: expect.arrayContaining(["session_id"]),
|
||||
});
|
||||
expect(
|
||||
patchRequestBody && !("$ref" in patchRequestBody)
|
||||
? patchRequestBody.content["application/json"]?.schema
|
||||
: undefined,
|
||||
).toMatchObject({
|
||||
properties: {
|
||||
is_title_manually_edited: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(patchOperation?.responses["200"]).toMatchObject({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentSessionUpdate" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(forkResponses["200"]).toMatchObject({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentSessionFork" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(forkResponses["201"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("documents runtime query parameters and error statuses", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
const renderOperation =
|
||||
document.paths["/api/v1/agent/render-references/{render_ref}"]?.get;
|
||||
|
||||
expect(renderOperation?.parameters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
in: "query",
|
||||
name: "session_id",
|
||||
required: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(Object.keys(renderOperation?.responses ?? {})).toEqual(
|
||||
expect.arrayContaining(["400", "500", "502"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import express, { Router } from "express";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import { generateAgentOpenApi } from "../../src/contracts/openapi.js";
|
||||
import { buildChatRouter } from "../../src/routes/chat.js";
|
||||
import { buildAgentPublicRouter } from "../../src/routes/publicApi.js";
|
||||
|
||||
describe("Agent public REST router", () => {
|
||||
let baseUrl = "";
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
const chatRouter = Router();
|
||||
chatRouter.use(express.json());
|
||||
chatRouter.post("/sessions/:session_id/runs", (req, res) => {
|
||||
res.json({ method: req.method, path: req.path, body: req.body });
|
||||
});
|
||||
chatRouter.delete("/sessions/:session_id/runs/current", (req, res) => {
|
||||
res.json({ method: req.method, path: req.path, body: req.body });
|
||||
});
|
||||
chatRouter.get("/auth-failure", (_req, res) => {
|
||||
res.status(503).json({ message: "authentication service unavailable" });
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/v1/agent", buildAgentPublicRouter(chatRouter));
|
||||
server = app.listen(0);
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("test server did not expose a TCP port");
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("matches the published OpenAPI method and path set", () => {
|
||||
type RouterLayer = {
|
||||
route?: {
|
||||
methods: Record<string, boolean>;
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
const router = buildChatRouter(
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
const layers = (router as unknown as { stack: RouterLayer[] }).stack;
|
||||
const runtimeOperations = layers
|
||||
.flatMap((layer) => {
|
||||
if (!layer.route) {
|
||||
return [];
|
||||
}
|
||||
const path = `/api/v1/agent${layer.route.path.replace(
|
||||
/:([^/]+)/g,
|
||||
"{$1}",
|
||||
)}`;
|
||||
return Object.entries(layer.route.methods)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([method]) => `${method.toUpperCase()} ${path}`);
|
||||
})
|
||||
.sort();
|
||||
const document = generateAgentOpenApi();
|
||||
const contractOperations = Object.entries(document.paths)
|
||||
.flatMap(([path, pathItem]) =>
|
||||
Object.entries(pathItem ?? {})
|
||||
.filter(([, operation]) => {
|
||||
return (
|
||||
operation &&
|
||||
typeof operation === "object" &&
|
||||
"responses" in operation
|
||||
);
|
||||
})
|
||||
.map(([method]) => `${method.toUpperCase()} ${path}`),
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(runtimeOperations).toEqual(contractOperations);
|
||||
});
|
||||
|
||||
test("serves session runs without rewriting the REST request", async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/v1/agent/sessions/session-1/runs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "hello" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(await response.json()).toEqual({
|
||||
method: "POST",
|
||||
path: "/sessions/session-1/runs",
|
||||
body: { message: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
test("serves DELETE current run without rewriting the HTTP method", async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/v1/agent/sessions/session-2/runs/current`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
expect(await response.json()).toEqual({
|
||||
method: "DELETE",
|
||||
path: "/sessions/session-2/runs/current",
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("wraps authentication middleware failures as Problem Details", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/v1/agent/auth-failure`);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/problem+json",
|
||||
);
|
||||
expect(body).toMatchObject({
|
||||
status: 503,
|
||||
code: "dependency_unavailable",
|
||||
detail: "authentication service unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,4 +61,79 @@ describe("SessionMetadataStore", () => {
|
||||
);
|
||||
expect(fetched?.title).toBe("新标题");
|
||||
});
|
||||
|
||||
it("does not expose a session across users or projects", async () => {
|
||||
await store.ensure({
|
||||
actorKey: "actor-a",
|
||||
projectId: "project-a",
|
||||
projectKey: "project-key-a",
|
||||
sessionId: "private-session",
|
||||
userId: "user-a",
|
||||
});
|
||||
|
||||
expect(
|
||||
await store.get(
|
||||
{
|
||||
actorKey: "actor-b",
|
||||
projectId: "project-a",
|
||||
projectKey: "project-key-a",
|
||||
userId: "user-b",
|
||||
},
|
||||
"private-session",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await store.get(
|
||||
{
|
||||
actorKey: "actor-a",
|
||||
projectId: "project-b",
|
||||
projectKey: "project-key-b",
|
||||
userId: "user-a",
|
||||
},
|
||||
"private-session",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects idempotent creation when the session belongs to another scope", async () => {
|
||||
await store.ensure({
|
||||
actorKey: "actor-a",
|
||||
projectId: "project-a",
|
||||
projectKey: "project-key-a",
|
||||
sessionId: "claimed-session",
|
||||
userId: "user-a",
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.ensure({
|
||||
actorKey: "actor-b",
|
||||
projectId: "project-b",
|
||||
projectKey: "project-key-b",
|
||||
sessionId: "claimed-session",
|
||||
userId: "user-b",
|
||||
}),
|
||||
).rejects.toThrow("session metadata ownership mismatch");
|
||||
});
|
||||
|
||||
it("keeps long session ids with the same slug prefix distinct", async () => {
|
||||
const prefix = "s".repeat(64);
|
||||
const firstSessionId = `${prefix}-first`;
|
||||
const secondSessionId = `${prefix}-second`;
|
||||
const context = {
|
||||
actorKey: "actor-long",
|
||||
projectId: "project-long",
|
||||
projectKey: "project-key-long",
|
||||
userId: "user-long",
|
||||
};
|
||||
|
||||
await store.ensure({ ...context, sessionId: firstSessionId });
|
||||
await store.ensure({ ...context, sessionId: secondSessionId });
|
||||
|
||||
expect((await store.get(context, firstSessionId))?.sessionId).toBe(
|
||||
firstSessionId,
|
||||
);
|
||||
expect((await store.get(context, secondSessionId))?.sessionId).toBe(
|
||||
secondSessionId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user