import { afterEach, describe, expect, it, vi } from "vitest"; import { createAgentApiClient } from "./client"; import type { AnswerViewStateWrite } from "./answer-view-state"; const remote = { version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 2, schema_digest: "digest", nodes: {}, block: { evidenceFilter: "all" }, updated_at: null }; const write: AnswerViewStateWrite = { message_id: "message", block_id: "block", expected_revision: 2, schema_digest: "digest", request_id: "intent", nodes: {}, block: { evidenceFilter: "all" } }; const token = (subject: string) => `header.${btoa(JSON.stringify({ iss: "https://identity.local", sub: subject }))}.signature`; afterEach(() => vi.unstubAllGlobals()); describe("authorized answer view state API", () => { it("keeps the message and block identity and uses the configured auth/project context", async () => { const fetcher = vi.fn().mockImplementation(async () => Response.json(remote)); vi.stubGlobal("fetch", fetcher); const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token("alice"), getProjectId: async () => "project" }); await expect(api.getAnswerViewState("session/one", "message", "block")).resolves.toEqual(remote); await api.putAnswerViewState("session/one", write); expect(fetcher.mock.calls[0][0]).toBe("https://agent.local/api/v1/session/session%2Fone/ui-state?message_id=message&block_id=block"); expect(fetcher.mock.calls[1][0]).toBe("https://agent.local/api/v1/session/session%2Fone/ui-state"); expect(fetcher.mock.calls[1][1].body).toBe(JSON.stringify(write)); for (const [, init] of fetcher.mock.calls) { expect(new Headers(init.headers).get("Authorization")).toBe(`Bearer ${token("alice")}`); expect(new Headers(init.headers).get("X-Project-Id")).toBe("project"); } }); it("retains structured conflict details for the explicit user choices", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ code: "ANSWER_VIEW_STATE_CONFLICT", detail: "new revision", current: remote }, { status: 409 }))); await expect(createAgentApiClient("https://agent.local").putAnswerViewState("session", write)).rejects.toMatchObject({ status: 409, code: "ANSWER_VIEW_STATE_CONFLICT", current: remote, }); }); it("rejects writes if a mutable token provider changes subject in the same project", async () => { const fetcher = vi.fn(async () => Response.json(remote)); vi.stubGlobal("fetch", fetcher); let subject = "alice"; const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token(subject), getProjectId: async () => "same-project" }); await api.getAnswerViewState("session", "message", "block"); subject = "bob"; await expect(api.putAnswerViewState("session", write)).rejects.toMatchObject({ code: "ANSWER_VIEW_IDENTITY_CHANGED" }); expect(fetcher).toHaveBeenCalledTimes(1); }); it("rejects a late read if identity changes while the request is in flight", async () => { let finish!: (response: Response) => void; const fetcher = vi.fn(() => new Promise((resolve) => { finish = resolve; })); vi.stubGlobal("fetch", fetcher); let subject = "alice"; const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token(subject) }); const read = api.getAnswerViewState("session", "message", "block"); await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); subject = "bob"; finish(Response.json(remote)); await expect(read).rejects.toMatchObject({ code: "ANSWER_VIEW_IDENTITY_CHANGED" }); }); it("rejects remote state containing unknown executable fields", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...remote, nodes: { x: { type: "Evidence", open: true, script: "bad" } } }))); await expect(createAgentApiClient("https://agent.local").getAnswerViewState("session", "message", "block")).rejects.toThrow(); }); });