diff --git a/README.md b/README.md index 4da8779..101b3da 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,17 @@ upstash search create --name my-search --region us-central1 --type DENSE upstash qstash list upstash qstash stats --qstash-id $QSTASH_ID --period 7d +# Blob +upstash blob create --name my-bucket --visibility private +upstash blob list +upstash blob credentials --bucket-id $BUCKET_ID + # Team upstash team list upstash team add-member --team-id $TEAM_ID --member-email you@example.com --role dev ``` -Run `upstash --help` (or `--help` on any subcommand) to discover everything else, and check the [full docs](https://upstash.com/docs/agent-resources/cli) for the complete catalog. +Run `upstash --help` (or `--help` on any subcommand) to discover everything else, and check the [full docs](https://upstash.com/docs/agent-resources/cli) for the complete catalog. `upstash blob credentials` returns temporary S3 credentials for use with AWS CLI, rclone, or an S3 SDK. ## Contributing diff --git a/src/cli.ts b/src/cli.ts index 0358e86..0f04720 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { registerTeam } from "./commands/team/index.js"; import { registerVector } from "./commands/vector/index.js"; import { registerSearch } from "./commands/search/index.js"; import { registerQStash } from "./commands/qstash/index.js"; +import { registerBlob } from "./commands/blob/index.js"; import { registerLogin } from "./commands/login.js"; import { registerLogout } from "./commands/logout.js"; import { registerStartRedis } from "./commands/start-redis.js"; @@ -46,5 +47,6 @@ registerTeam(program); registerVector(program); registerSearch(program); registerQStash(program); +registerBlob(program); program.parseAsync().catch(handleError); diff --git a/src/commands/blob/create.ts b/src/commands/blob/create.ts new file mode 100644 index 0000000..8e36afb --- /dev/null +++ b/src/commands/blob/create.ts @@ -0,0 +1,43 @@ +import { Command, InvalidArgumentError } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { BLOB_VISIBILITIES } from "../../types.js"; +import type { BlobBucket, BlobVisibility } from "../../types.js"; + +function parseVisibility(value: string): BlobVisibility { + if ((BLOB_VISIBILITIES as readonly string[]).includes(value)) { + return value as BlobVisibility; + } + throw new InvalidArgumentError( + `--visibility must be one of: ${BLOB_VISIBILITIES.join(", ")}; got "${value}"`, + ); +} + +export function registerBlobCreate(blob: Command): void { + blob + .command("create") + .description("Create a Blob bucket") + .requiredOption("--name ", "Bucket name") + .option( + "--visibility ", + `Bucket visibility. Available: ${BLOB_VISIBILITIES.join(", ")}`, + parseVisibility, + "private", + ) + .option("--cors ", "Allowed CORS origins (space-separated)") + .action( + async ( + flags: { name: string; visibility: BlobVisibility; cors?: string[] }, + command: Command, + ) => { + const auth = resolveAuth(command); + const bucket = await request(auth, "POST", "/v2/blob/bucket", { + name: flags.name, + visibility: flags.visibility, + cors: flags.cors, + }); + printJSON(bucket); + }, + ); +} diff --git a/src/commands/blob/credentials.ts b/src/commands/blob/credentials.ts new file mode 100644 index 0000000..4a40b2f --- /dev/null +++ b/src/commands/blob/credentials.ts @@ -0,0 +1,160 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { HttpError, request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { BlobBucket, BlobS3Credentials } from "../../types.js"; + +const BLOB_CREDENTIALS_URL = "https://blob.upstash.io/v1/credentials"; +const RETRYABLE_STATUSES = new Set([429, 503]); +const DEFAULT_RETRY_DELAY_MS = 2000; +const MAX_RETRY_DELAY_MS = 10000; +const MAX_RETRIES = 3; + +type Sleep = (ms: number) => Promise; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseErrorMessage(text: string, status: number): string { + let message = text || `HTTP ${status}`; + try { + const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }; + const candidate = parsed.error ?? parsed.message; + if (typeof candidate === "string" && candidate.length > 0) { + message = candidate; + } + } catch { + // keep original message + } + return message; +} + +function getRetryDelayMs(retryAfter: string | null): number { + const parsed = Number(retryAfter); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_RETRY_DELAY_MS; + } + return Math.min(parsed * 1000, MAX_RETRY_DELAY_MS); +} + +function validateCredentials(data: unknown): BlobS3Credentials { + if (!data || typeof data !== "object") { + throw new Error("Blob credentials response must be a JSON object"); + } + + const credentials = data as Record; + const requiredStrings = [ + "accessKeyId", + "secretAccessKey", + "sessionToken", + "endpoint", + "bucket", + "region", + ] as const; + + for (const field of requiredStrings) { + if (typeof credentials[field] !== "string" || credentials[field].length === 0) { + throw new Error(`Blob credentials response is missing a valid ${field}`); + } + } + + if (typeof credentials.expiresAt !== "number" || !Number.isFinite(credentials.expiresAt)) { + throw new Error("Blob credentials response is missing a valid expiresAt"); + } + + let endpoint: URL; + try { + endpoint = new URL(credentials.endpoint as string); + } catch { + throw new Error("Blob credentials response has an invalid endpoint URL"); + } + + if (endpoint.protocol !== "https:") { + throw new Error("Blob credentials endpoint must use HTTPS"); + } + + if (!endpoint.hostname.endsWith(".r2.cloudflarestorage.com")) { + throw new Error( + "Blob credentials endpoint must target a .r2.cloudflarestorage.com hostname", + ); + } + + return credentials as unknown as BlobS3Credentials; +} + +export async function fetchBlobCredentials( + token: string, + pause: Sleep = sleep, +): Promise { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { + const response = await fetch(BLOB_CREDENTIALS_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const text = await response.text(); + + if (response.ok) { + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + throw new Error("Blob credentials response must be valid JSON"); + } + return validateCredentials(parsed); + } + + if (response.status === 401) { + throw new HttpError("Blob bucket token was rejected", response.status); + } + + if (RETRYABLE_STATUSES.has(response.status) && attempt < MAX_RETRIES) { + await pause(getRetryDelayMs(response.headers.get("Retry-After"))); + continue; + } + + throw new HttpError(parseErrorMessage(text, response.status), response.status); + } + + throw new Error("Blob credentials request failed after retries"); +} + +function resolveBucketToken(flags: { bucketId?: string }, command: Command): Promise { + if (flags.bucketId) { + const auth = resolveAuth(command); + return request(auth, "GET", `/v2/blob/bucket/${flags.bucketId}`).then((bucket) => { + if (typeof bucket.token === "string" && bucket.token.length > 0) { + return bucket.token; + } + throw new Error(`Blob bucket ${flags.bucketId} did not return a current token`); + }); + } + + const token = process.env.UPSTASH_BLOB_TOKEN; + if (typeof token === "string" && token.length > 0) { + return Promise.resolve(token); + } + + return Promise.reject( + new Error( + "Blob credentials require either --bucket-id with Upstash account authentication or a non-empty UPSTASH_BLOB_TOKEN environment variable", + ), + ); +} + +export function registerBlobCredentials(blob: Command): void { + blob + .command("credentials") + .description( + "Get temporary S3 credentials for a Blob bucket; expiresAt is the credential expiry", + ) + .option("--bucket-id ", "Blob bucket ID") + .action(async (flags: { bucketId?: string }, command: Command) => { + const token = await resolveBucketToken(flags, command); + const credentials = await fetchBlobCredentials(token); + printJSON(credentials); + }); +} diff --git a/src/commands/blob/delete.ts b/src/commands/blob/delete.ts new file mode 100644 index 0000000..7f749b7 --- /dev/null +++ b/src/commands/blob/delete.ts @@ -0,0 +1,21 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerBlobDelete(blob: Command): void { + blob + .command("delete") + .description("Delete a Blob bucket") + .requiredOption("--bucket-id ", "Blob bucket ID") + .option("--dry-run", "Preview the action without executing it") + .action(async (flags: { bucketId: string; dryRun?: boolean }, command: Command) => { + if (flags.dryRun) { + printJSON({ action: "delete", bucket_id: flags.bucketId, dry_run: true }); + return; + } + const auth = resolveAuth(command); + await request(auth, "DELETE", `/v2/blob/bucket/${flags.bucketId}`); + printJSON({ deleted: true, bucket_id: flags.bucketId }); + }); +} diff --git a/src/commands/blob/get.ts b/src/commands/blob/get.ts new file mode 100644 index 0000000..c560201 --- /dev/null +++ b/src/commands/blob/get.ts @@ -0,0 +1,23 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { BlobBucket } from "../../types.js"; + +export function registerBlobGet(blob: Command): void { + blob + .command("get") + .description("Get details of a Blob bucket") + .requiredOption("--bucket-id ", "Blob bucket ID") + .option("--hide-credentials", "Omit bucket tokens from output") + .action(async (flags: { bucketId: string; hideCredentials?: boolean }, command: Command) => { + const auth = resolveAuth(command); + const bucket = await request(auth, "GET", `/v2/blob/bucket/${flags.bucketId}`); + if (!flags.hideCredentials) { + printJSON(bucket); + return; + } + const { token: _token, token_next: _tokenNext, ...safeBucket } = bucket; + printJSON(safeBucket); + }); +} diff --git a/src/commands/blob/index.ts b/src/commands/blob/index.ts new file mode 100644 index 0000000..8652c91 --- /dev/null +++ b/src/commands/blob/index.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { registerBlobCreate } from "./create.js"; +import { registerBlobList } from "./list.js"; +import { registerBlobGet } from "./get.js"; +import { registerBlobDelete } from "./delete.js"; +import { registerBlobCredentials } from "./credentials.js"; + +export function registerBlob(program: Command): void { + const blob = program.command("blob").description("Manage Blob buckets"); + + registerBlobCreate(blob); + registerBlobList(blob); + registerBlobGet(blob); + registerBlobDelete(blob); + registerBlobCredentials(blob); +} diff --git a/src/commands/blob/list.ts b/src/commands/blob/list.ts new file mode 100644 index 0000000..562641f --- /dev/null +++ b/src/commands/blob/list.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { BlobBucket } from "../../types.js"; + +export function registerBlobList(blob: Command): void { + blob + .command("list") + .description("List Blob buckets") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const buckets = await request(auth, "GET", "/v2/blob/bucket"); + printJSON(buckets); + }); +} diff --git a/src/types.ts b/src/types.ts index fec1d95..37ef0d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -171,3 +171,41 @@ export interface QStashUser { timeout?: number; creation_time?: number; } + +// ── Blob ───────────────────────────────────────────────────────────────────── + +export const BLOB_VISIBILITIES = ["private", "public"] as const; +export type BlobVisibility = (typeof BLOB_VISIBILITIES)[number]; + +export interface BlobBucketEvent { + type: string; + message: string; + observed_at: number; + [key: string]: unknown; +} + +export interface BlobBucket { + customer_id: string; + id: string; + name: string; + hash_for_domain: string; + visibility: BlobVisibility; + endpoint: string; + pw_version: number; + creation_time: number; + cors?: string[]; + created_by?: string; + events?: BlobBucketEvent[]; + token?: string; + token_next?: string; +} + +export interface BlobS3Credentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + endpoint: string; + bucket: string; + region: string; + expiresAt: number; +} diff --git a/tests/helpers/program.ts b/tests/helpers/program.ts index a13a010..1c52236 100644 --- a/tests/helpers/program.ts +++ b/tests/helpers/program.ts @@ -28,6 +28,13 @@ export async function createQStashProgram(): Promise { return p; } +export async function createBlobProgram(): Promise { + const { registerBlob } = await import("../../src/commands/blob/index.js"); + const p = new Command().exitOverride(); + registerBlob(p); + return p; +} + export async function createTeamProgram(): Promise { const { registerTeam } = await import("../../src/commands/team/index.js"); const p = new Command().exitOverride(); diff --git a/tests/integration/blob.test.ts b/tests/integration/blob.test.ts new file mode 100644 index 0000000..1e47bcd --- /dev/null +++ b/tests/integration/blob.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { HttpError } from "../../src/client.js"; +import { createBlobProgram, runCommand } from "../helpers/program.js"; +import type { BlobBucket, BlobS3Credentials } from "../../src/types.js"; + +const runIntegration = process.env.RUN_BLOB_INTEGRATION === "1"; +const describeBlob = runIntegration ? describe : describe.skip; +const TEST_NAME = `cli-blob-${Date.now()}`; +const DEADLINE_MS = 120000; +const POLL_INTERVAL_MS = 5000; +const TEST_TIMEOUT_MS = DEADLINE_MS + 30000; +const CLEANUP_TIMEOUT_MS = DEADLINE_MS + 30000; + +let bucketId: string | undefined; + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForCredentials(id: string): Promise { + const deadline = Date.now() + DEADLINE_MS; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const program = await createBlobProgram(); + return await runCommand(program, ["blob", "credentials", "--bucket-id", id]) as BlobS3Credentials; + } catch (error) { + // A fresh coordinator record can exist before the Blob worker has created + // its matching bucket row. The worker returns 401 during that window, so + // it is transient only in this bounded create-then-poll integration flow. + if (error instanceof HttpError && [401, 429, 503].includes(error.status)) { + lastError = error; + await sleep(POLL_INTERVAL_MS); + continue; + } + + throw error; + } + } + + throw lastError instanceof Error + ? lastError + : new Error("Timed out waiting for Blob credentials to become ready"); +} + +async function cleanupBucket(id: string): Promise { + const deadline = Date.now() + DEADLINE_MS; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const program = await createBlobProgram(); + await runCommand(program, ["blob", "delete", "--bucket-id", id]); + return; + } catch (error) { + if (error instanceof HttpError) { + if (error.status === 404) { + return; + } + + if (error.status >= 500 && error.status < 600) { + lastError = error; + await sleep(POLL_INTERVAL_MS); + continue; + } + } + + throw error; + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`Timed out deleting Blob bucket ${id}`); +} + +beforeAll(async () => { + if (!runIntegration) return; + const program = await createBlobProgram(); + const bucket = await runCommand(program, [ + "blob", + "create", + "--name", + TEST_NAME, + "--visibility", + "private", + ]) as BlobBucket; + + expect(bucket.id).toBeDefined(); + bucketId = bucket.id; +}); + +afterAll(async () => { + if (!bucketId) return; + await cleanupBucket(bucketId); +}, CLEANUP_TIMEOUT_MS); + +describeBlob("blob integration lifecycle", () => { + it("lists and gets the created bucket", async () => { + const listProgram = await createBlobProgram(); + const buckets = await runCommand(listProgram, ["blob", "list"]) as BlobBucket[]; + expect(buckets.some((bucket) => bucket.id === bucketId)).toBe(true); + + const getProgram = await createBlobProgram(); + const bucket = await runCommand(getProgram, ["blob", "get", "--bucket-id", bucketId!]) as BlobBucket; + expect(bucket.id).toBe(bucketId); + expect(bucket.name).toBe(TEST_NAME); + expect(bucket.visibility).toBe("private"); + }); + + it("returns temporary S3 credentials once provisioning is ready", async () => { + const credentials = await waitForCredentials(bucketId!); + expect(credentials.bucket).toBe(bucketId); + expect(credentials.region).toBe("auto"); + expect(credentials.endpoint.startsWith("https://")).toBe(true); + expect(credentials.endpoint.includes(".r2.cloudflarestorage.com")).toBe(true); + expect(credentials.expiresAt).toBeGreaterThan(Date.now() / 1000); + }, TEST_TIMEOUT_MS); +}); diff --git a/tests/unit/blob.test.ts b/tests/unit/blob.test.ts new file mode 100644 index 0000000..38bcdde --- /dev/null +++ b/tests/unit/blob.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBlobProgram, runCommand } from "../helpers/program.js"; +import { fetchBlobCredentials } from "../../src/commands/blob/credentials.js"; +import type { BlobBucket, BlobS3Credentials } from "../../src/types.js"; + +const originalEnv = { ...process.env }; + +function makeBucket(overrides: Partial = {}): BlobBucket { + return { + customer_id: "cust_123", + id: "bucket_123", + name: "my-bucket", + hash_for_domain: "hash_123", + visibility: "private", + endpoint: "https://bucket_123.example.com", + pw_version: 1, + creation_time: 123, + token: "token_current", + token_next: "token_next", + ...overrides, + }; +} + +function makeCredentials(overrides: Partial = {}): BlobS3Credentials { + return { + accessKeyId: "access", + secretAccessKey: "secret", + sessionToken: "session", + endpoint: "https://account.r2.cloudflarestorage.com", + bucket: "bucket_123", + region: "auto", + expiresAt: 1234567890, + ...overrides, + }; +} + +beforeEach(() => { + process.env = { + ...originalEnv, + UPSTASH_EMAIL: "user@example.com", + UPSTASH_API_KEY: "api-key", + }; + delete process.env.UPSTASH_BLOB_TOKEN; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + process.env = { ...originalEnv }; +}); + +describe("blob command registration", () => { + it("registers the expected blob subcommands", async () => { + const program = await createBlobProgram(); + const blob = program.commands.find((command) => command.name() === "blob"); + + expect(blob).toBeDefined(); + expect(blob?.description()).toBe("Manage Blob buckets"); + expect(blob?.commands.map((command) => command.name())).toEqual([ + "create", + "list", + "get", + "delete", + "credentials", + ]); + }); +}); + +describe("blob CRUD commands", () => { + it("create defaults to private visibility", async () => { + const bucket = makeBucket(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(bucket), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "create", "--name", "my-bucket"]); + + expect(result).toEqual(bucket); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.upstash.com/v2/blob/bucket"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + name: "my-bucket", + visibility: "private", + }); + }); + + it("create passes CORS origins correctly", async () => { + const bucket = makeBucket({ cors: ["https://a.example.com", "https://b.example.com"] }); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(bucket), { status: 200 }), + ); + + const program = await createBlobProgram(); + await runCommand(program, [ + "blob", + "create", + "--name", + "my-bucket", + "--cors", + "https://a.example.com", + "https://b.example.com", + ]); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + name: "my-bucket", + visibility: "private", + cors: ["https://a.example.com", "https://b.example.com"], + }); + }); + + it("list uses the expected method and path", async () => { + const buckets = [makeBucket({ token: undefined, token_next: undefined })]; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(buckets), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "list"]); + + expect(result).toEqual(buckets); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.upstash.com/v2/blob/bucket"); + expect(init.method).toBe("GET"); + }); + + it("get preserves credentials by default", async () => { + const bucket = makeBucket(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(bucket), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "get", "--bucket-id", "bucket_123"]); + + expect(result).toEqual(bucket); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.upstash.com/v2/blob/bucket/bucket_123"); + expect(init.method).toBe("GET"); + }); + + it("get --hide-credentials removes both token fields without mutating the response", async () => { + const bucket = makeBucket(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(bucket), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, [ + "blob", + "get", + "--bucket-id", + "bucket_123", + "--hide-credentials", + ]); + + expect(result).toEqual({ + customer_id: "cust_123", + id: "bucket_123", + name: "my-bucket", + hash_for_domain: "hash_123", + visibility: "private", + endpoint: "https://bucket_123.example.com", + pw_version: 1, + creation_time: 123, + }); + expect(bucket.token).toBe("token_current"); + expect(bucket.token_next).toBe("token_next"); + }); + + it("delete dry-run makes no HTTP request and returns the preview shape", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const program = await createBlobProgram(); + const result = await runCommand(program, [ + "blob", + "delete", + "--bucket-id", + "bucket_123", + "--dry-run", + ]); + + expect(result).toEqual({ action: "delete", bucket_id: "bucket_123", dry_run: true }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("delete uses the expected method and path", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('"OK"', { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "delete", "--bucket-id", "bucket_123"]); + + expect(result).toEqual({ deleted: true, bucket_id: "bucket_123" }); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.upstash.com/v2/blob/bucket/bucket_123"); + expect(init.method).toBe("DELETE"); + }); +}); + +describe("blob credentials command", () => { + it("by bucket id fetches the bucket first, then exchanges its token", async () => { + const bucket = makeBucket({ id: "bucket_456", token: "bucket-token" }); + const credentials = makeCredentials({ bucket: "bucket_456" }); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify(bucket), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(credentials), { status: 200 })); + + const program = await createBlobProgram(); + const result = await runCommand(program, [ + "blob", + "credentials", + "--bucket-id", + "bucket_456", + ]); + + expect(result).toEqual(credentials); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://api.upstash.com/v2/blob/bucket/bucket_456"); + expect(fetchSpy.mock.calls[1]?.[0]).toBe("https://blob.upstash.io/v1/credentials"); + expect((fetchSpy.mock.calls[1]?.[1] as RequestInit).headers).toEqual({ + Authorization: "Bearer bucket-token", + }); + }); + + it("without bucket id uses UPSTASH_BLOB_TOKEN and skips Developer API auth", async () => { + process.env.UPSTASH_BLOB_TOKEN = "env-bucket-token"; + const credentials = makeCredentials(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(credentials), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "credentials"]); + + expect(result).toEqual(credentials); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://blob.upstash.io/v1/credentials"); + }); + + it("explicit bucket id wins over an ambient UPSTASH_BLOB_TOKEN", async () => { + process.env.UPSTASH_BLOB_TOKEN = "ambient-token"; + const bucket = makeBucket({ id: "bucket_789", token: "fresh-token" }); + const credentials = makeCredentials({ bucket: "bucket_789" }); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify(bucket), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(credentials), { status: 200 })); + + const program = await createBlobProgram(); + await runCommand(program, ["blob", "credentials", "--bucket-id", "bucket_789"]); + + expect((fetchSpy.mock.calls[1]?.[1] as RequestInit).headers).toEqual({ + Authorization: "Bearer fresh-token", + }); + }); + + it("fails clearly when no bucket token source is available", async () => { + delete process.env.UPSTASH_BLOB_TOKEN; + delete process.env.UPSTASH_EMAIL; + delete process.env.UPSTASH_API_KEY; + + const program = await createBlobProgram(); + + await expect(runCommand(program, ["blob", "credentials"])) + .rejects.toThrow(/either --bucket-id.*UPSTASH_BLOB_TOKEN/); + }); + + it("prints successful credential responses unchanged", async () => { + process.env.UPSTASH_BLOB_TOKEN = "env-bucket-token"; + const credentials = { + ...makeCredentials(), + extra: "preserved", + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(credentials), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "credentials"]); + + expect(result).toEqual(credentials); + }); + + it("rejects invalid credential payloads and unexpected endpoints", async () => { + const invalidPayloads = [ + makeCredentials({ accessKeyId: "" }), + { ...makeCredentials(), expiresAt: Number.NaN }, + makeCredentials({ endpoint: "http://account.r2.cloudflarestorage.com" }), + makeCredentials({ endpoint: "https://example.com" }), + ]; + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + for (const payload of invalidPayloads) { + process.env.UPSTASH_BLOB_TOKEN = "env-bucket-token"; + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })); + const program = await createBlobProgram(); + await expect(runCommand(program, ["blob", "credentials"])) + .rejects.toThrow(/Blob credentials response|Blob credentials endpoint/); + } + }); + + it("reports 401 as rejected authentication", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"error":"unauthorized"}', { status: 401 }), + ); + + await expect( + fetchBlobCredentials("bad-token", async () => { + throw new Error("should not sleep"); + }), + ).rejects.toThrow(/rejected/); + }); + + it("retries 429 and 503 with retry-after or fallback delays, then succeeds", async () => { + const credentials = makeCredentials(); + const delays: number[] = []; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response('{"error":"slow down"}', { status: 429, headers: { "Retry-After": "1" } })) + .mockResolvedValueOnce(new Response('{"error":"unavailable"}', { status: 503 })) + .mockResolvedValueOnce(new Response(JSON.stringify(credentials), { status: 200 })); + + const result = await fetchBlobCredentials("bucket-token", async (ms) => { + delays.push(ms); + }); + + expect(result).toEqual(credentials); + expect(delays).toEqual([1000, 2000]); + }); + + it("caps retries and surfaces the final response error", async () => { + const delays: number[] = []; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response('{"error":"busy-1"}', { status: 429, headers: { "Retry-After": "999" } })) + .mockResolvedValueOnce(new Response('{"error":"busy-2"}', { status: 503, headers: { "Retry-After": "nope" } })) + .mockResolvedValueOnce(new Response('{"error":"busy-3"}', { status: 429, headers: { "Retry-After": "3" } })) + .mockResolvedValueOnce(new Response('{"error":"still busy"}', { status: 503 })); + + await expect( + fetchBlobCredentials("bucket-token", async (ms) => { + delays.push(ms); + }), + ).rejects.toThrow(/still busy/); + expect(delays).toEqual([10000, 2000, 3000]); + }); +});