78 lines
5.9 KiB
TypeScript
78 lines
5.9 KiB
TypeScript
import { expect, it } from "bun:test";
|
|
import express, { type Request } from "express";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { config } from "../../src/config.js";
|
|
import { attachTrustedAgentContext, getLocalAgentContext, ProductContextError } from "../../src/context/localContext.js";
|
|
import { registerAnswerViewStateRoutes } from "../../src/routes/answer-view-state-routes.js";
|
|
import { AnswerViewStateStore } from "../../src/sessions/answer-view-state-store.js";
|
|
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
|
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
|
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
|
|
|
it("authenticates state reads and writes against the persisted session owner and returns safe conflicts", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const originalEnvironment = config.NODE_ENV;
|
|
const root = await mkdtemp(join(tmpdir(), "answer-state-routes-"));
|
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
|
const states = new AnswerViewStateStore(workspaces);
|
|
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
|
|
const url = String(input);
|
|
const headers = new Headers(init?.headers);
|
|
if (url.endsWith("/api/v1/agent-auth-context")) return Response.json({ user_id: headers.get("Authorization") === "Bearer OWNER" ? "owner" : "other", project_id: headers.get("X-Project-Id"), network: "supply" });
|
|
if (url.endsWith("/api/v1/access-context")) return Response.json({ user_id: headers.get("Authorization") === "Bearer OWNER" ? "owner" : "other" });
|
|
return originalFetch(input, init);
|
|
}) as typeof fetch;
|
|
config.NODE_ENV = "production";
|
|
const ownerRequest = { header: (name: string) => ({ authorization: "Bearer OWNER", "x-project-id": "project" })[name.toLowerCase() as "authorization" | "x-project-id"] } as Request;
|
|
await attachTrustedAgentContext(ownerRequest);
|
|
const context = getLocalAgentContext(ownerRequest);
|
|
const sessionId = "state-session";
|
|
await sessions.ensure({ ...context, sessionId });
|
|
await new SessionUiStateStore(join(root, "unused"), workspaces).write({ ...context, sessionId }, { sessionId, messages: [{ id: "assistant", role: "assistant", parts: [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", fallback_text: "fallback", spec: { root: "evidence", elements: { evidence: { type: "Evidence", props: { title: "Evidence", summary: "Summary", source: "source", evidence_id: null, observed_at: null, kind: "observed" }, children: [] } } } } }] }] });
|
|
const app = express();
|
|
app.use(express.json({ limit: "2mb" }));
|
|
app.use(async (req, res, next) => {
|
|
try { await attachTrustedAgentContext(req); next(); }
|
|
catch (error) { res.status(error instanceof ProductContextError ? error.status : 503).end(); }
|
|
});
|
|
registerAnswerViewStateRoutes(app, { sessions, states });
|
|
const server = app.listen(0, "127.0.0.1");
|
|
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/session/${sessionId}/ui-state`;
|
|
const headers = { Authorization: "Bearer OWNER", "X-Project-Id": "project", "Content-Type": "application/json" };
|
|
try {
|
|
expect((await originalFetch(`${url}?message_id=assistant&block_id=block`)).status).toBe(401);
|
|
for (const bad of [{ ...headers, Authorization: "Bearer OTHER" }, { ...headers, "X-Project-Id": "other" }]) {
|
|
expect((await originalFetch(`${url}?message_id=assistant&block_id=block`, { headers: bad })).status).toBe(404);
|
|
expect((await originalFetch(url, { method: "PUT", headers: bad, body: '{}' })).status).toBe(404);
|
|
}
|
|
const initial = await originalFetch(`${url}?message_id=assistant&block_id=block`, { headers });
|
|
expect(initial.headers.get("Cache-Control")).toBe("private, no-store");
|
|
const first = await initial.json() as { schema_digest: string };
|
|
const payload = { message_id: "assistant", block_id: "block", schema_digest: first.schema_digest, expected_revision: 0, request_id: "request", nodes: { evidence: { type: "Evidence", open: true } }, block: { evidenceFilter: "all" } };
|
|
expect((await originalFetch(url, { method: "PUT", headers, body: JSON.stringify(payload) })).status).toBe(200);
|
|
const conflict = await originalFetch(url, { method: "PUT", headers, body: JSON.stringify({ ...payload, request_id: "new" }) });
|
|
expect(conflict.status).toBe(409);
|
|
expect(await conflict.json()).toMatchObject({ code: "ANSWER_VIEW_STATE_CONFLICT", current: { revision: 1 } });
|
|
expect((await originalFetch(`${url}?message_id=assistant&block_id=missing`, { headers })).status).toBe(404);
|
|
expect((await originalFetch(`${url}?message_id=assistant&block_id=block&unexpected=x`, { headers })).status).toBe(400);
|
|
await writeFile(workspaces.path({ ...context, sessionId }, "answer-view-state.json"), JSON.stringify({ damaged: `${root}/private-form-content` }));
|
|
for (const response of [
|
|
await originalFetch(`${url}?message_id=assistant&block_id=block`, { headers }),
|
|
await originalFetch(url, { method: "PUT", headers, body: JSON.stringify(payload) }),
|
|
]) {
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "ANSWER_VIEW_STATE_UNAVAILABLE", detail: "answer view state unavailable" });
|
|
}
|
|
await sessions.remove((await sessions.get(context, sessionId))!);
|
|
expect((await originalFetch(url, { method: "PUT", headers, body: JSON.stringify(payload) })).status).toBe(404);
|
|
} finally {
|
|
globalThis.fetch = originalFetch; config.NODE_ENV = originalEnvironment;
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|