Publish the unified MIT-licensed Stuffbox SDK source
Hop-State: A_06FPAF0M83E7T0JJ7EP2WQ0 Hop-Proposal: R_06FPAF04S0D4G8TA8TNK3C0 Hop-Task: T_06FPADTV2YD4PD5GMTK3150 Hop-Attempt: AT_06FPADTV2ZYKEY8GH3JTK98
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
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<FetchLike>().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 registers a self-hosted callback and returns its client ID", async () => {
|
||||
const fetcher = vi.fn<FetchLike>().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({
|
||||
selfHosted: true,
|
||||
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({
|
||||
registration_mode: "self_hosted",
|
||||
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("rejects a connection response that omits the client ID", async () => {
|
||||
const fetcher = vi.fn<FetchLike>().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({
|
||||
selfHosted: true,
|
||||
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<FetchLike>().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({
|
||||
selfHosted: true,
|
||||
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<FetchLike>().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<FetchLike>().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<FetchLike>();
|
||||
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<FetchLike>()
|
||||
.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<FetchLike>().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<FetchLike>()
|
||||
.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<FetchLike>()
|
||||
.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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user