diff --git a/src/lib/__tests__/api.test.ts b/src/lib/__tests__/api.test.ts index 9cd9aa9..43fb224 100644 --- a/src/lib/__tests__/api.test.ts +++ b/src/lib/__tests__/api.test.ts @@ -1,12 +1,17 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -vi.mock("../env.ts", () => ({ - env: { +const { mockEnv } = vi.hoisted(() => ({ + mockEnv: { SAMPLEX_API_URL: "http://localhost:9999", SAMPLEX_LOG_LEVEL: "silent", + SAMPLEX_API_KEY: "", }, })); +vi.mock("../env.ts", () => ({ + env: mockEnv, +})); + const { mockLoadCredentials, mockSaveCredentials, mockClearCredentials } = vi.hoisted(() => ({ mockLoadCredentials: vi.fn(), mockSaveCredentials: vi.fn(), @@ -61,17 +66,21 @@ function makeResponse( } // --------------------------------------------------------------------------- -// rpc() +// rpc() — OAuth (default, no API key) // --------------------------------------------------------------------------- describe("rpc()", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); + mockEnv.SAMPLEX_API_KEY = ""; }); - it("throws 'Not logged in' when credentials are null", async () => { + it("throws 'Not logged in' with API key hint when credentials are null", async () => { mockLoadCredentials.mockReturnValue(null); - await expect(rpc("some.procedure")).rejects.toThrow("Not logged in"); + const err = await rpc("some.procedure").catch((e: Error) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("Not logged in"); + expect((err as Error).message).toContain("SAMPLEX_API_KEY"); }); it("makes an authenticated POST with Bearer token and correct URL", async () => { @@ -141,6 +150,80 @@ describe("rpc()", () => { }); }); +// --------------------------------------------------------------------------- +// rpc() — API key auth (SAMPLEX_API_KEY set) +// --------------------------------------------------------------------------- +describe("rpc() with SAMPLEX_API_KEY", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + mockEnv.SAMPLEX_API_KEY = ""; + }); + + it("uses x-api-key header instead of Bearer token when SAMPLEX_API_KEY is set", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(makeResponse(200, { ok: true })); + + await rpc("site.list"); + + const [, init] = spy.mock.calls[0]!; + const headers = init?.headers as Record; + expect(headers["x-api-key"]).toBe("sk_test_key_123"); + expect(headers["Authorization"]).toBeUndefined(); + }); + + it("does not check or require OAuth credentials when API key is set", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; + mockLoadCredentials.mockReturnValue(null); // No OAuth creds + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(200, { ok: true })); + + // Should not throw "Not logged in" — API key is sufficient + await expect(rpc("site.list")).resolves.toEqual({ ok: true }); + expect(mockLoadCredentials).not.toHaveBeenCalled(); + }); + + it("does not attempt token refresh when API key is set", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(makeResponse(200, { data: "ok" })); + + await rpc("site.list"); + + // Only one fetch (the RPC call), no refresh call + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0]).toContain("/api/rpc/"); + }); + + it("on 401 with API key: throws API key error and does not clear credentials", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_bad_key"; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(401, "Unauthorized")); + + const err = await rpc("some.procedure").catch((e: Error) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("API key is invalid or expired"); + expect((err as Error).message).toContain("SAMPLEX_API_KEY"); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); + + it("API key takes precedence over existing OAuth credentials", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_priority_key"; + mockLoadCredentials.mockReturnValue(validCreds()); // OAuth creds exist + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(makeResponse(200, { ok: true })); + + await rpc("site.deploy"); + + const [, init] = spy.mock.calls[0]!; + const headers = init?.headers as Record; + expect(headers["x-api-key"]).toBe("sk_priority_key"); + expect(headers["Authorization"]).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // rpcUpload() // --------------------------------------------------------------------------- @@ -148,6 +231,7 @@ describe("rpcUpload()", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); + mockEnv.SAMPLEX_API_KEY = ""; }); it("throws 'Not logged in' when credentials are null", async () => { @@ -215,6 +299,71 @@ describe("rpcUpload()", () => { }); }); +// --------------------------------------------------------------------------- +// rpcUpload() — API key auth +// --------------------------------------------------------------------------- +describe("rpcUpload() with SAMPLEX_API_KEY", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + mockEnv.SAMPLEX_API_KEY = ""; + }); + + it("uses x-api-key header for uploads when SAMPLEX_API_KEY is set", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_upload_key"; + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(makeResponse(200, { json: { id: "abc" }, meta: [] })); + + const blob = new Blob(["file contents"], { type: "text/plain" }); + await rpcUpload("site.upload", { blob, fieldName: "archive" }, { name: "test" }); + + const [, init] = spy.mock.calls[0]!; + const headers = init?.headers as Record; + expect(headers["x-api-key"]).toBe("sk_upload_key"); + expect(headers["Authorization"]).toBeUndefined(); + }); + + it("on 401 with API key: throws API key error", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_bad_key"; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(401, "Unauthorized")); + + const file = { blob: new Blob(["data"]), fieldName: "file" }; + const err = await rpcUpload("upload.file", file).catch((e: Error) => e); + expect((err as Error).message).toContain("API key is invalid or expired"); + expect((err as Error).message).toContain("SAMPLEX_API_KEY"); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// rpc() / rpcUpload() — non-401 errors with API key +// --------------------------------------------------------------------------- +describe("non-401 errors with SAMPLEX_API_KEY", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + mockEnv.SAMPLEX_API_KEY = ""; + }); + + it("rpc: on 500 with API key, throws with response body text", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(500, "Internal Server Error")); + + await expect(rpc("some.procedure")).rejects.toThrow("Internal Server Error"); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); + + it("rpcUpload: on 500 with API key, throws with response body text", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(500, "Internal Server Error")); + + const file = { blob: new Blob(["data"]), fieldName: "file" }; + await expect(rpcUpload("upload.file", file)).rejects.toThrow("Internal Server Error"); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // Token refresh (getValidCredentials via rpc) // --------------------------------------------------------------------------- @@ -222,6 +371,7 @@ describe("token refresh", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); + mockEnv.SAMPLEX_API_KEY = ""; }); it("uses token as-is when it is far from expiry", async () => { diff --git a/src/lib/api.ts b/src/lib/api.ts index 3290f5f..4179807 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,5 @@ import { loadCredentials, saveCredentials, clearCredentials } from "./config"; +import { env } from "./env"; import { apiFetch, apiUrl, UPLOAD_TIMEOUT_MS } from "./http"; import { log } from "./logger"; import { CLI_VERSION } from "./version"; @@ -28,8 +29,14 @@ function handleResponseErrors(response: Response): void { checkUpgradeRequired(response); if (response.status === 401) { - clearCredentials(); - throw new Error("Session expired. Run `samplex login` again."); + if (!env.SAMPLEX_API_KEY) { + clearCredentials(); + } + throw new Error( + env.SAMPLEX_API_KEY + ? "API key is invalid or expired. Check your SAMPLEX_API_KEY environment variable." + : "Session expired. Run `samplex login` again.", + ); } } @@ -85,16 +92,25 @@ async function getValidCredentials() { } } -async function requireCredentials() { +async function requireAuth(): Promise> { + // API key takes precedence — no OAuth needed + if (env.SAMPLEX_API_KEY) { + log.debug("Using SAMPLEX_API_KEY for authentication"); + return { "x-api-key": env.SAMPLEX_API_KEY }; + } + + // Fall back to OAuth credentials const credentials = await getValidCredentials(); if (!credentials) { - throw new Error("Not logged in. Run `samplex login` first."); + throw new Error( + "Not logged in. Run `samplex login` or set the SAMPLEX_API_KEY environment variable.", + ); } - return credentials; + return { Authorization: `Bearer ${credentials.accessToken}` }; } export async function rpc(procedure: string, input?: unknown): Promise { - const credentials = await requireCredentials(); + const authHeaders = await requireAuth(); const path = procedure.replaceAll(".", "/"); const url = apiUrl(`/api/rpc/api-reference/${path}`); @@ -103,7 +119,7 @@ export async function rpc(procedure: string, input?: unknown): Prom if (input !== undefined) log.debug(" input:", JSON.stringify(input)); const headers: Record = { - Authorization: `Bearer ${credentials.accessToken}`, + ...authHeaders, }; let body: string | undefined; @@ -136,7 +152,7 @@ export async function rpcUpload( file: { blob: Blob; fieldName: string }, input: Record = {}, ): Promise { - const credentials = await requireCredentials(); + const authHeaders = await requireAuth(); const path = procedure.replaceAll(".", "/"); const url = apiUrl(`/api/rpc/${path}`); @@ -160,7 +176,7 @@ export async function rpcUpload( const response = await apiFetch(url, { method: "POST", - headers: { Authorization: `Bearer ${credentials.accessToken}` }, + headers: authHeaders, body: form, timeoutMs: UPLOAD_TIMEOUT_MS, }); diff --git a/src/lib/env.ts b/src/lib/env.ts index 2372ec0..03bc604 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -6,4 +6,5 @@ export const env = { | "warn" | "error" | "silent", + SAMPLEX_API_KEY: process.env.SAMPLEX_API_KEY || "", };