bb04d47a29
Hop-State: A_06FPARPGMNVEAN3XBVH0QD8 Hop-Proposal: R_06FPARNXMP3DGP5VQGGX4Z8 Hop-Task: T_06FPAR9KH2V5ED7V65JEN00 Hop-Attempt: AT_06FPAR9KH3NZ1CV8RGPJDSG
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
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 creates a callback identity when the client ID is omitted", 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({
|
|
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<FetchLike>().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<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({
|
|
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({
|
|
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");
|
|
});
|
|
});
|