import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AssistantCloudAPI, CloudAPIError } from "../AssistantCloudAPI"; import { CloudResponseError } from "../cloudResponse"; import { assistantCloudTraceExportOptions } from "../telemetry"; const createAccessToken = (subject: string) => `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from(JSON.stringify({ exp: 4102444800, sub: subject })).toString("base64url")}.sig`; describe("AssistantCloudAPI", () => { beforeEach(() => { vi.restoreAllMocks(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("serializes query params, merges auth headers, and sends JSON body", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, headers: new Headers(), json: vi.fn().mockResolvedValue({ data: "ok" }), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); await api.makeRawRequest("/threads", { method: "POST", query: { archived: false, detailed: true, page: 2 }, headers: { "X-Test": "1" }, body: { hello: "world" }, }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]!; // false values are excluded from query params; others are serialized expect(url.toString()).toBe( "https://backend.assistant-api.com/v1/threads?detailed=true&page=2", ); // Real API-key auth headers are merged with custom headers expect(init.headers).toMatchObject({ Authorization: "Bearer test-key", "Aui-User-Id": "u-1", "Aui-Workspace-Id": "w-1", "Aui-Sdk": expect.stringMatching(/^assistant-cloud\//), "Content-Type": "application/json", "X-Test": "1", }); expect(init.method).toBe("POST"); expect(init.body).toBe(JSON.stringify({ hello: "world" })); }); it("ignores identities that cannot travel in a header", () => { const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); api.registerSdk({ name: "bad name", version: "1.0.0" }); api.registerSdk({ name: "@scope/pkg", version: "1.0.0 ok" }); api.registerSdk({ name: "@scope/pkg\ttab", version: "1.0.0" }); api.registerSdk({ name: "@scope/ok", version: " 1.0.0 " }); expect(api.sdkHeader().split(" ")).toEqual([ expect.stringMatching(/^assistant-cloud\//), "@scope/ok/1.0.0", ]); }); it("sends each registered SDK identity once in registration order", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, headers: new Headers(), json: vi.fn().mockResolvedValue({}), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); api.registerSdk({ name: " @assistant-ui/core ", version: " 0.3.18 " }); api.registerSdk({ name: "@assistant-ui/core", version: "0.3.18" }); api.registerSdk({ name: "@assistant-ui/ai-sdk", version: "0.0.5" }); api.registerSdk({ name: " ", version: "0.0.5" }); api.registerSdk({ name: "@assistant-ui/react-langgraph", version: " " }); await api.makeRawRequest("/threads", { headers: { "Aui-Sdk": "overridden" }, }); const [, init] = fetchMock.mock.calls[0]!; expect(init.headers).toMatchObject({ "Aui-Sdk": api.sdkHeader() }); expect(api.sdkHeader().split(" ")).toEqual([ expect.stringMatching(/^assistant-cloud\//), "@assistant-ui/core/0.3.18", "@assistant-ui/ai-sdk/0.0.5", ]); }); it("uses custom baseUrl when provided with apiKey config", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, headers: new Headers(), json: vi.fn().mockResolvedValue({}), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://custom.example.com", apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); await api.makeRawRequest("/threads"); const [url] = fetchMock.mock.calls[0]!; expect(url.toString()).toBe("https://custom.example.com/v1/threads"); }); it("strips a trailing slash from a custom baseUrl", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, headers: new Headers(), json: vi.fn().mockResolvedValue({}), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://custom.example.com/", apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); await api.makeRawRequest("/threads"); const [url] = fetchMock.mock.calls[0]!; expect(url.toString()).toBe("https://custom.example.com/v1/threads"); }); it("uses the same base URL for anonymous auth and trace export with two trailing slashes", async () => { const baseUrl = "https://custom.example.com//"; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ access_token: createAccessToken("user"), refresh_token: { token: "refresh-token", expires_at: "2099-01-01", }, }), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl, anonymous: true }); await api.initializeAuth(); const traceUrl = assistantCloudTraceExportOptions({ apiKey: "test-key", baseUrl, }).url; expect(fetchMock).toHaveBeenCalledWith( traceUrl.replace("/v1/traces", "/v1/auth/tokens/anonymous"), expect.objectContaining({ method: "POST" }), ); }); it("rejects before fetch when auth token callback returns null", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", authToken: async () => null, }); await expect(api.makeRawRequest("/threads")).rejects.toThrow( "Authorization failed", ); expect(fetchMock).not.toHaveBeenCalled(); }); it("rejects old headers if auth is invalidated while headers resolve", async () => { const userAToken = createAccessToken("user-a"); const fetchMock = vi.fn().mockResolvedValue({ ok: true, headers: new Headers(), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", authToken: async () => userAToken, }); const oldHeaders = await api._auth.getAuthHeaders(); if (!oldHeaders) throw new Error("Expected auth headers"); let resolveHeaders: (headers: Record) => void = () => {}; vi.spyOn(api._auth, "getAuthHeaders").mockImplementationOnce( () => new Promise((resolve) => { resolveHeaders = resolve; }), ); const oldRequest = api.makeRawRequest("/threads"); api.invalidateAuth(); resolveHeaders(oldHeaders); await expect(oldRequest).rejects.toThrow("Authorization failed"); expect(fetchMock).not.toHaveBeenCalled(); }); it("uses fresh headers for a request started after invalidation", async () => { const userAToken = createAccessToken("user-a"); const userBToken = createAccessToken("user-b"); const internalUserAToken = createAccessToken("internal-user-a"); let currentToken = userAToken; const authToken = vi.fn(async () => currentToken); const fetchMock = vi .fn() .mockResolvedValueOnce({ ok: true, headers: new Headers({ Authorization: `Bearer ${internalUserAToken}` }), }) .mockResolvedValue({ ok: true, headers: new Headers() }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", authToken, }); await api.makeRawRequest("/threads"); expect(await api._auth.getAuthHeaders()).toEqual({ Authorization: `Bearer ${internalUserAToken}`, }); currentToken = userBToken; api.invalidateAuth(); await api.makeRawRequest("/threads"); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ Authorization: `Bearer ${userAToken}`, }); expect(fetchMock.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: `Bearer ${userBToken}`, }); expect(authToken).toHaveBeenCalledTimes(2); }); it("stops authenticating after the provider session is invalidated", async () => { const userAToken = createAccessToken("user-a"); const internalUserAToken = createAccessToken("internal-user-a"); let currentToken: string | null = userAToken; const authToken = vi.fn(async () => currentToken); const fetchMock = vi.fn().mockResolvedValue({ ok: true, headers: new Headers({ Authorization: `Bearer ${internalUserAToken}` }), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", authToken, }); await api.makeRawRequest("/threads"); expect(await api._auth.getAuthHeaders()).toEqual({ Authorization: `Bearer ${internalUserAToken}`, }); currentToken = null; api.invalidateAuth(); await expect(api.makeRawRequest("/threads")).rejects.toThrow( "Authorization failed", ); expect(fetchMock).toHaveBeenCalledOnce(); expect(authToken).toHaveBeenCalledTimes(2); }); it("does not let an old response restore a superseded identity", async () => { const userAToken = createAccessToken("user-a"); const userBToken = createAccessToken("user-b"); const internalUserAToken = createAccessToken("internal-user-a"); let currentToken = userAToken; let resolveUserAResponse: (response: Response) => void = () => {}; const userAResponse = new Promise((resolve) => { resolveUserAResponse = resolve; }); const fetchMock = vi .fn() .mockImplementationOnce(() => userAResponse) .mockResolvedValue({ ok: true, headers: new Headers(), } as Response); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", authToken: async () => currentToken, }); const userARequest = api.makeRawRequest("/threads"); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); currentToken = userBToken; api.invalidateAuth(); await api.makeRawRequest("/threads"); resolveUserAResponse({ ok: true, headers: new Headers({ Authorization: `Bearer ${internalUserAToken}`, }), } as Response); await userARequest; await api.makeRawRequest("/threads"); expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ Authorization: `Bearer ${userAToken}`, }); expect(fetchMock.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: `Bearer ${userBToken}`, }); expect(fetchMock.mock.calls[2]?.[1]?.headers).toMatchObject({ Authorization: `Bearer ${userBToken}`, }); }); it("accepts token rotations from concurrent requests in the same session", async () => { const providerToken = createAccessToken("user-a"); const firstInternalToken = createAccessToken("internal-user-a-1"); const secondInternalToken = createAccessToken("internal-user-a-2"); const authToken = vi.fn(async () => providerToken); let resolveFirstResponse: (response: Response) => void = () => {}; let resolveSecondResponse: (response: Response) => void = () => {}; const firstResponse = new Promise((resolve) => { resolveFirstResponse = resolve; }); const secondResponse = new Promise((resolve) => { resolveSecondResponse = resolve; }); const fetchMock = vi .fn() .mockImplementationOnce(() => firstResponse) .mockImplementationOnce(() => secondResponse) .mockResolvedValue({ ok: true, headers: new Headers() } as Response); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", authToken, }); const firstRequest = api.makeRawRequest("/threads"); const secondRequest = api.makeRawRequest("/threads"); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); resolveFirstResponse({ ok: true, headers: new Headers({ Authorization: `Bearer ${firstInternalToken}`, }), } as Response); await firstRequest; resolveSecondResponse({ ok: true, headers: new Headers({ Authorization: `Bearer ${secondInternalToken}`, }), } as Response); await secondRequest; await api.makeRawRequest("/threads"); expect(fetchMock.mock.calls[2]?.[1]?.headers).toMatchObject({ Authorization: `Bearer ${secondInternalToken}`, }); expect(authToken).toHaveBeenCalledOnce(); }); it("does not cache a malformed rotated token", async () => { const providerToken = createAccessToken("user-a"); const authToken = vi.fn(async () => providerToken); const fetchMock = vi .fn() .mockResolvedValueOnce({ ok: true, headers: new Headers({ Authorization: "Bearer malformed" }), }) .mockResolvedValue({ ok: true, headers: new Headers() }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", authToken, }); await expect(api.makeRawRequest("/threads")).rejects.toThrow( "Unable to determine the token expiry", ); await api.makeRawRequest("/threads"); expect(fetchMock.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: `Bearer ${providerToken}`, }); expect(authToken).toHaveBeenCalledOnce(); }); it("returns false from initializeAuth when auth token callback returns null", async () => { const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", authToken: async () => null, }); await expect(api.initializeAuth()).resolves.toBe(false); }); it("rejects initializeAuth with context for malformed anonymous responses", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ access_token: 123, refresh_token: null, }), }), ); const api = new AssistantCloudAPI({ baseUrl: "https://test.example.com", anonymous: true, }); await expect(api.initializeAuth()).rejects.toThrow( new CloudResponseError( 'Invalid Assistant Cloud response for "anonymous auth token response.access_token": expected a string', ), ); }); it("preserves the current error shape for JSON error responses without a code", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 400, headers: new Headers(), text: vi .fn() .mockResolvedValue( JSON.stringify({ message: "invalid request payload" }), ), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); const error = await api.makeRawRequest("/threads").catch((e) => e); expect(error).toBeInstanceOf(CloudAPIError); expect(error.name).toBe("CloudAPIError"); expect(error.message).toBe("invalid request payload"); expect(error.status).toBe(400); expect(error.code).toBeUndefined(); expect(error.details).toBeUndefined(); }); it("exposes the plan-limit error code and details", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 402, headers: new Headers(), text: vi.fn().mockResolvedValue( JSON.stringify({ error: "plan_limit_reached", plan: "free", period_end: "2026-10-01T00:00:00.000Z", cap: 100, }), ), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); const error = await api.makeRawRequest("/threads").catch((e) => e); expect(error).toBeInstanceOf(CloudAPIError); expect(error.message).toBe( 'Request failed with status 402, {"error":"plan_limit_reached","plan":"free","period_end":"2026-10-01T00:00:00.000Z","cap":100}', ); expect(error.status).toBe(402); expect(error.code).toBe("plan_limit_reached"); expect(error.details).toEqual({ plan: "free", period_end: "2026-10-01T00:00:00.000Z", cap: 100, }); }); it("exposes rate-limit error codes with empty details", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 429, headers: new Headers(), text: vi .fn() .mockResolvedValue(JSON.stringify({ error: "rate_limited" })), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); const error = await api.makeRawRequest("/threads").catch((e) => e); expect(error).toBeInstanceOf(CloudAPIError); expect(error.message).toBe( 'Request failed with status 429, {"error":"rate_limited"}', ); expect(error.status).toBe(429); expect(error.code).toBe("rate_limited"); expect(error.details).toEqual({}); }); it("throws generic error with status for non-JSON error responses", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 502, headers: new Headers(), text: vi.fn().mockResolvedValue("Bad Gateway"), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); const error = await api.makeRawRequest("/threads").catch((e) => e); expect(error).toBeInstanceOf(CloudAPIError); expect(error.message).toBe("Request failed with status 502, Bad Gateway"); expect(error.status).toBe(502); }); it("makeRequest returns parsed JSON from a successful response", async () => { const responseData = { threads: [{ id: "t-1" }] }; const fetchMock = vi.fn().mockResolvedValue({ ok: true, headers: new Headers(), text: vi.fn().mockResolvedValue(JSON.stringify(responseData)), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); const result = await api.makeRequest("/threads"); expect(result).toEqual(responseData); }); it("makeRequest returns undefined from a successful empty response", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204, headers: new Headers(), text: vi.fn().mockResolvedValue(""), }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); await expect( api.makeRequest("/threads/t-1", { method: "DELETE" }), ).resolves.toBeUndefined(); }); it("makeRequest returns undefined when content-length is zero", async () => { const text = vi.fn().mockResolvedValue(""); const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: new Headers({ "content-length": "0" }), text, }); vi.stubGlobal("fetch", fetchMock); const api = new AssistantCloudAPI({ apiKey: "test-key", userId: "u-1", workspaceId: "w-1", }); await expect(api.makeRequest("/threads/t-1")).resolves.toBeUndefined(); expect(text).not.toHaveBeenCalled(); }); });