import { describe, expect, it, vi } from "vitest"; import { StuffboxClient, StuffboxError, type FetchLike, } from "../src"; function json(value: unknown, init: ResponseInit = {}): Response { const headers = new Headers(init.headers); headers.set("content-type", "application/json"); return new Response(JSON.stringify(value), { ...init, headers, }); } describe("StuffboxClient", () => { it("creates a PKCE connection request using the protocol wire names", async () => { const fetcher = vi.fn().mockResolvedValue( json({ request_id: "cr_1", client_id: "app_0123456789abcdefghijklmnop", callback_url: "https://synapsis.test/stuffbox/callback", authorization_url: "https://stuffbox.test/authorize/cr_1", expires_at: "2026-01-01T00:05:00.000Z", }), ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test/", fetch: fetcher, accessToken: "must-not-leak", }); await expect( client.createConnectionRequest({ clientId: "app_0123456789abcdefghijklmnop", callbackUrl: "https://synapsis.test/stuffbox/callback", codeChallenge: "c".repeat(43), scopes: ["assets:read", "assets:write"], state: "state-with-enough-entropy", }), ).resolves.toEqual({ id: "cr_1", clientId: "app_0123456789abcdefghijklmnop", callbackUrl: "https://synapsis.test/stuffbox/callback", authorizationUrl: "https://stuffbox.test/authorize/cr_1", expiresAt: "2026-01-01T00:05:00.000Z", }); const [url, init] = fetcher.mock.calls[0]; expect(url).toBe("https://stuffbox.test/api/v1/connection-requests"); expect(init?.method).toBe("POST"); expect(init?.headers).not.toHaveProperty("Authorization"); expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_0123456789abcdefghijklmnop", callback_url: "https://synapsis.test/stuffbox/callback", code_challenge: "c".repeat(43), code_challenge_method: "S256", scopes: ["assets:read", "assets:write"], state: "state-with-enough-entropy", }); }); it("automatically creates a callback identity when the client ID is omitted", async () => { const fetcher = vi.fn().mockResolvedValue( json({ request_id: "cr_self_hosted", client_id: "app_self_hosted_0123456789", callback_url: "https://synapsis.test/stuffbox/callback", authorization_url: "https://stuffbox.test/authorize/cr_self_hosted", expires_at: "2026-01-01T00:05:00.000Z", }), ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, }); await expect( client.createConnectionRequest({ callbackUrl: "https://synapsis.test/stuffbox/callback", codeChallenge: "c".repeat(43), scopes: ["assets:read", "assets:write"], state: "state-with-enough-entropy", }), ).resolves.toEqual({ id: "cr_self_hosted", clientId: "app_self_hosted_0123456789", callbackUrl: "https://synapsis.test/stuffbox/callback", authorizationUrl: "https://stuffbox.test/authorize/cr_self_hosted", expiresAt: "2026-01-01T00:05:00.000Z", }); expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({ callback_url: "https://synapsis.test/stuffbox/callback", code_challenge: "c".repeat(43), code_challenge_method: "S256", scopes: ["assets:read", "assets:write"], state: "state-with-enough-entropy", }); }); it("keeps the deprecated selfHosted option wire-compatible", async () => { const fetcher = vi.fn().mockResolvedValue( json({ request_id: "cr_legacy", client_id: "app_legacy_0123456789abcdef", callback_url: "https://legacy.test/stuffbox/callback", authorization_url: "https://stuffbox.test/authorize/cr_legacy", expires_at: "2026-01-01T00:05:00.000Z", }), ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher }); await client.createConnectionRequest({ selfHosted: true, callbackUrl: "https://legacy.test/stuffbox/callback", codeChallenge: "c".repeat(43), scopes: ["assets:read"], state: "state-with-enough-entropy", }); expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toMatchObject({ registration_mode: "self_hosted", }); }); it("rejects a connection response that omits the client ID", async () => { const fetcher = vi.fn().mockResolvedValue( json({ request_id: "cr_missing_client", callback_url: "https://synapsis.test/stuffbox/callback", authorization_url: "https://stuffbox.test/authorize/cr_missing_client", expires_at: "2026-01-01T00:05:00.000Z", }), ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, }); const error = await client .createConnectionRequest({ callbackUrl: "https://synapsis.test/stuffbox/callback", codeChallenge: "c".repeat(43), scopes: ["assets:read"], state: "state-with-enough-entropy", }) .catch((cause: unknown) => cause); expect(error).toBeInstanceOf(StuffboxError); expect(error).toMatchObject({ code: "invalid_response", message: "Missing string field: clientId", }); }); it("rejects a connection response that omits the canonical callback URL", async () => { const fetcher = vi.fn().mockResolvedValue( json({ request_id: "cr_missing_callback", client_id: "app_self_hosted_0123456789", authorization_url: "https://stuffbox.test/authorize/cr_missing_callback", expires_at: "2026-01-01T00:05:00.000Z", }), ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, }); const error = await client .createConnectionRequest({ callbackUrl: "https://synapsis.test/stuffbox/callback", codeChallenge: "c".repeat(43), scopes: ["assets:read"], state: "state-with-enough-entropy", }) .catch((cause: unknown) => cause); expect(error).toBeInstanceOf(StuffboxError); expect(error).toMatchObject({ code: "invalid_response", message: "Missing string field: callbackUrl", }); }); it("dispatches authorization-code and refresh grants through one endpoint", async () => { const fetcher = vi.fn().mockImplementation(async () => json({ access_token: "at_new", token_type: "bearer", expires_in: 600, refresh_token: "rt_new", refresh_token_expires_in: 2_592_000, scope: "assets:read assets:write", }), ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, }); const exchanged = await client.exchangeAuthorizationCode({ clientId: "app_0123456789abcdefghijklmnop", code: "code", codeVerifier: "verifier", redirectUri: "https://node.test/callback", }); const refreshed = await client.refreshTokens("rt_old"); expect(exchanged).toEqual(refreshed); expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({ grant_type: "authorization_code", client_id: "app_0123456789abcdefghijklmnop", code: "code", code_verifier: "verifier", redirect_uri: "https://node.test/callback", }); expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({ grant_type: "refresh_token", refresh_token: "rt_old", }); expect(fetcher.mock.calls[0][0]).toBe("https://stuffbox.test/api/v1/token"); expect(fetcher.mock.calls[1][0]).toBe("https://stuffbox.test/api/v1/token"); }); it("adds a lazy bearer token only to protected requests", async () => { const accessToken = vi.fn().mockResolvedValue("at_secret"); const fetcher = vi.fn().mockResolvedValue( json({ items: [], next_cursor: "next" }), ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, accessToken, }); await expect(client.listAssets({ limit: 10 })).resolves.toEqual({ items: [], nextCursor: "next", }); expect(accessToken).toHaveBeenCalledOnce(); expect(fetcher.mock.calls[0][0]).toBe( "https://stuffbox.test/api/v1/assets?limit=10", ); expect(fetcher.mock.calls[0][1]?.headers).toHaveProperty( "Authorization", "Bearer at_secret", ); }); it("rejects an asset page size outside the public API range before fetching", async () => { const fetcher = vi.fn(); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, accessToken: "at_secret", }); await expect(client.listAssets({ limit: 101 })).rejects.toThrow( "Asset list limit must be between 1 and 100", ); expect(fetcher).not.toHaveBeenCalled(); }); it("creates and completes a direct upload without handling file bytes", async () => { const fetcher = vi .fn() .mockResolvedValueOnce( json({ upload_id: "up_1", upload_url: "https://objects.test/signed", method: "PUT", required_headers: { "Content-Type": "image/png" }, expires_at: "2026-01-01T00:10:00.000Z", completion_token: "uct_1", }), ) .mockResolvedValueOnce( json({ asset: { id: "as_1", public_id: "pub_1", canonical_url: "https://stuffbox.test/f/pub_1", original_filename: "image.png", mime_type: "image/png", byte_size: 12, status: "active", created_at: "2026-01-01T00:00:00.000Z", }, }), ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, accessToken: "at_1", }); const session = await client.createUpload({ filename: "image.png", mimeType: "image/png", size: 12, }); const asset = await client.completeUpload(session.id, { completionToken: session.completionToken, }); expect(session.requiredHeaders).toEqual({ "Content-Type": "image/png" }); expect(asset.url).toBe("https://stuffbox.test/f/pub_1"); expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({ completion_token: "uct_1", }); }); it("throws typed structured errors with retry metadata", async () => { const fetcher: FetchLike = async () => json( { error: { code: "quota_exceeded", message: "Storage quota exceeded", details: { remaining_bytes: 4 }, request_id: "req_1", }, }, { status: 409, headers: { "retry-after": "12" } }, ); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, accessToken: "at_1", }); const error = await client .createUpload({ filename: "large.mp4", mimeType: "video/mp4", size: 100 }) .catch((cause: unknown) => cause); expect(error).toBeInstanceOf(StuffboxError); expect(error).toMatchObject({ code: "quota_exceeded", status: 409, requestId: "req_1", retryAfter: 12, details: { remaining_bytes: 4 }, }); }); it("supports endpoint overrides and per-request bearer suppression", async () => { const fetcher = vi.fn().mockResolvedValue(json({ items: [] })); const client = new StuffboxClient({ baseUrl: "https://example.test/custom", fetch: fetcher, accessToken: "at_default", endpoints: { assets: "/assets" }, }); await client.listAssets({}, { accessToken: null }); expect(fetcher.mock.calls[0][0]).toBe("https://example.test/custom/assets"); expect(fetcher.mock.calls[0][1]?.headers).not.toHaveProperty("Authorization"); }); it("revokes opaque tokens without adding another bearer credential", async () => { const fetcher = vi .fn() .mockResolvedValue(new Response(null, { status: 204 })); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, accessToken: "at_configured", }); await client.revokeToken({ token: "rt_revoked", tokenTypeHint: "refresh_token", }); expect(fetcher.mock.calls[0][0]).toBe("https://stuffbox.test/api/v1/revoke"); expect(fetcher.mock.calls[0][1]?.headers).not.toHaveProperty("Authorization"); expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({ token: "rt_revoked", token_type_hint: "refresh_token", }); }); it("gets and deletes an owner-scoped asset by encoded ID", async () => { const fetcher = vi .fn() .mockResolvedValueOnce( json({ asset: { id: "asset/one", public_asset_id: "pub_1", url: "https://stuffbox.test/f/pub_1", filename: "photo.jpg", mimeType: "image/jpeg", byteSize: 20, status: "active", createdAt: "2026-01-01T00:00:00.000Z", }, }), ) .mockResolvedValueOnce(new Response(null, { status: 204 })); const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher, accessToken: "at_1", }); await expect(client.getAsset("asset/one")).resolves.toMatchObject({ id: "asset/one", publicId: "pub_1", }); await client.deleteAsset("asset/one"); expect(fetcher.mock.calls[0][0]).toBe( "https://stuffbox.test/api/v1/assets/asset%2Fone", ); expect(fetcher.mock.calls[1][1]?.method).toBe("DELETE"); }); });