|
| 1 | +import { describe, it, expect, beforeAll, afterAll } from "vitest"; |
| 2 | +import { randomBytes } from "node:crypto"; |
| 3 | +import type { AddressInfo } from "node:net"; |
| 4 | +import { Hono, serve } from "@emulators/core"; |
| 5 | +import { Store, WebhookDispatcher, authMiddleware, type TokenMap } from "@emulators/core"; |
| 6 | +import { put, head, list, del, copy, BlobNotFoundError, BlobAccessError } from "@vercel/blob"; |
| 7 | +import { vercelPlugin } from "../index.js"; |
| 8 | + |
| 9 | +const token = "vercel_blob_rw_teststore_secret"; |
| 10 | +const storeId = "teststore"; |
| 11 | + |
| 12 | +let emulatorUrl: string; |
| 13 | +let apiUrl: string; |
| 14 | +let closeServer: () => Promise<void>; |
| 15 | + |
| 16 | +beforeAll(async () => { |
| 17 | + const store = new Store(); |
| 18 | + const webhooks = new WebhookDispatcher(); |
| 19 | + const tokenMap: TokenMap = new Map(); |
| 20 | + const app = new Hono(); |
| 21 | + app.use("*", authMiddleware(tokenMap)); |
| 22 | + |
| 23 | + const server = serve({ fetch: app.fetch, port: 0 }); |
| 24 | + await new Promise<void>((resolve, reject) => { |
| 25 | + server.once("listening", () => resolve()); |
| 26 | + server.once("error", reject); |
| 27 | + }); |
| 28 | + const { port } = server.address() as AddressInfo; |
| 29 | + emulatorUrl = `http://127.0.0.1:${port}`; |
| 30 | + apiUrl = `${emulatorUrl}/api/blob`; |
| 31 | + |
| 32 | + // Blob URLs embed the base URL, so register routes after the port is known. |
| 33 | + vercelPlugin.register(app as any, store, webhooks, emulatorUrl, tokenMap); |
| 34 | + vercelPlugin.seed?.(store, emulatorUrl); |
| 35 | + |
| 36 | + process.env.VERCEL_BLOB_API_URL = apiUrl; |
| 37 | + process.env.BLOB_READ_WRITE_TOKEN = token; |
| 38 | + |
| 39 | + closeServer = () => |
| 40 | + new Promise<void>((resolve, reject) => { |
| 41 | + server.close((err) => (err ? reject(err) : resolve())); |
| 42 | + }); |
| 43 | +}); |
| 44 | + |
| 45 | +afterAll(async () => { |
| 46 | + delete process.env.VERCEL_BLOB_API_URL; |
| 47 | + delete process.env.BLOB_READ_WRITE_TOKEN; |
| 48 | + await closeServer(); |
| 49 | +}); |
| 50 | + |
| 51 | +describe("Vercel Blob via the @vercel/blob SDK", () => { |
| 52 | + it("put uploads bytes and the returned url serves them back", async () => { |
| 53 | + const data = randomBytes(1024); |
| 54 | + const result = await put("round-trip/data.bin", data, { access: "public", token }); |
| 55 | + |
| 56 | + expect(result.pathname).toBe("round-trip/data.bin"); |
| 57 | + expect(result.url).toBe(`${emulatorUrl}/blob/${storeId}/round-trip/data.bin`); |
| 58 | + expect(result.contentType).toBe("application/octet-stream"); |
| 59 | + expect(result.contentDisposition).toBe('attachment; filename="data.bin"'); |
| 60 | + |
| 61 | + const res = await fetch(result.url); |
| 62 | + expect(res.status).toBe(200); |
| 63 | + const body = Buffer.from(await res.arrayBuffer()); |
| 64 | + expect(body.equals(data)).toBe(true); |
| 65 | + expect(res.headers.get("etag")).toBe(result.etag); |
| 66 | + expect(res.headers.get("cache-control")).toBe("public, max-age=2592000"); |
| 67 | + }); |
| 68 | + |
| 69 | + it("put with addRandomSuffix appends a suffix before the extension", async () => { |
| 70 | + const result = await put("suffix/report.txt", "hello suffix", { |
| 71 | + access: "public", |
| 72 | + token, |
| 73 | + addRandomSuffix: true, |
| 74 | + }); |
| 75 | + |
| 76 | + expect(result.pathname).toMatch(/^suffix\/report-[a-z0-9]+\.txt$/); |
| 77 | + expect(result.pathname).not.toBe("suffix/report.txt"); |
| 78 | + |
| 79 | + const res = await fetch(result.url); |
| 80 | + expect(res.status).toBe(200); |
| 81 | + expect(await res.text()).toBe("hello suffix"); |
| 82 | + expect(res.headers.get("content-type")).toContain("text/plain"); |
| 83 | + }); |
| 84 | + |
| 85 | + it("put with allowOverwrite: false rejects when the blob already exists", async () => { |
| 86 | + await put("conflict/file.txt", "first", { access: "public", token }); |
| 87 | + await expect( |
| 88 | + put("conflict/file.txt", "second", { access: "public", token, allowOverwrite: false }), |
| 89 | + ).rejects.toThrow(/allowOverwrite/); |
| 90 | + |
| 91 | + // The original content is untouched and allowOverwrite: true replaces it. |
| 92 | + const before = await fetch(`${emulatorUrl}/blob/${storeId}/conflict/file.txt`); |
| 93 | + expect(await before.text()).toBe("first"); |
| 94 | + |
| 95 | + await put("conflict/file.txt", "second", { access: "public", token, allowOverwrite: true }); |
| 96 | + const after = await fetch(`${emulatorUrl}/blob/${storeId}/conflict/file.txt`); |
| 97 | + expect(await after.text()).toBe("second"); |
| 98 | + }); |
| 99 | + |
| 100 | + it("head returns blob metadata", async () => { |
| 101 | + const uploaded = await put("meta/info.json", JSON.stringify({ ok: true }), { |
| 102 | + access: "public", |
| 103 | + token, |
| 104 | + cacheControlMaxAge: 60, |
| 105 | + }); |
| 106 | + |
| 107 | + const meta = await head(uploaded.url, { token }); |
| 108 | + expect(meta.pathname).toBe("meta/info.json"); |
| 109 | + expect(meta.url).toBe(uploaded.url); |
| 110 | + expect(meta.downloadUrl).toBe(uploaded.downloadUrl); |
| 111 | + expect(meta.size).toBe(Buffer.byteLength(JSON.stringify({ ok: true }))); |
| 112 | + expect(meta.contentType).toBe("application/json"); |
| 113 | + expect(meta.contentDisposition).toBe('attachment; filename="info.json"'); |
| 114 | + expect(meta.cacheControl).toBe("public, max-age=60"); |
| 115 | + expect(meta.etag).toBe(uploaded.etag); |
| 116 | + expect(meta.uploadedAt).toBeInstanceOf(Date); |
| 117 | + expect(Number.isNaN(meta.uploadedAt.getTime())).toBe(false); |
| 118 | + }); |
| 119 | + |
| 120 | + it("head falls back to BLOB_READ_WRITE_TOKEN when no token option is given", async () => { |
| 121 | + const uploaded = await put("meta/env-token.txt", "env token", { access: "public", token }); |
| 122 | + const meta = await head(uploaded.url); |
| 123 | + expect(meta.pathname).toBe("meta/env-token.txt"); |
| 124 | + }); |
| 125 | + |
| 126 | + it("put accepts SDK OIDC auth with an explicit storeId", async () => { |
| 127 | + const uploaded = await put("auth/oidc.txt", "oidc token", { |
| 128 | + access: "public", |
| 129 | + oidcToken: "local-oidc-token", |
| 130 | + storeId, |
| 131 | + }); |
| 132 | + |
| 133 | + expect(uploaded.url).toBe(`${emulatorUrl}/blob/${storeId}/auth/oidc.txt`); |
| 134 | + const res = await fetch(uploaded.url); |
| 135 | + expect(res.status).toBe(200); |
| 136 | + expect(await res.text()).toBe("oidc token"); |
| 137 | + }); |
| 138 | + |
| 139 | + it("copy duplicates source content into the destination blob", async () => { |
| 140 | + const source = await put("copy/source.txt", "copy me", { access: "public", token }); |
| 141 | + const copied = await copy(source.url, "copy/dest.txt", { access: "public", token }); |
| 142 | + |
| 143 | + expect(copied.pathname).toBe("copy/dest.txt"); |
| 144 | + const res = await fetch(copied.url); |
| 145 | + expect(res.status).toBe(200); |
| 146 | + expect(await res.text()).toBe("copy me"); |
| 147 | + |
| 148 | + const meta = await head(copied.url, { token }); |
| 149 | + expect(meta.size).toBe("copy me".length); |
| 150 | + expect(meta.etag).toBe(source.etag); |
| 151 | + }); |
| 152 | + |
| 153 | + it("list filters by prefix and returns metadata", async () => { |
| 154 | + await put("listing/a.txt", "a", { access: "public", token }); |
| 155 | + await put("listing/b.txt", "b", { access: "public", token }); |
| 156 | + await put("other/c.txt", "c", { access: "public", token }); |
| 157 | + |
| 158 | + const result = await list({ prefix: "listing/", token }); |
| 159 | + const pathnames = result.blobs.map((b) => b.pathname); |
| 160 | + expect(pathnames).toEqual(["listing/a.txt", "listing/b.txt"]); |
| 161 | + expect(result.hasMore).toBe(false); |
| 162 | + for (const blob of result.blobs) { |
| 163 | + expect(blob.url).toBe(`${emulatorUrl}/blob/${storeId}/${blob.pathname}`); |
| 164 | + expect(blob.size).toBe(1); |
| 165 | + expect(blob.uploadedAt).toBeInstanceOf(Date); |
| 166 | + expect(blob.etag).toMatch(/^"[0-9a-f]{64}"$/); |
| 167 | + } |
| 168 | + }); |
| 169 | + |
| 170 | + it("list paginates with limit and cursor", async () => { |
| 171 | + await put("paging/1.txt", "1", { access: "public", token }); |
| 172 | + await put("paging/2.txt", "2", { access: "public", token }); |
| 173 | + await put("paging/3.txt", "3", { access: "public", token }); |
| 174 | + |
| 175 | + const first = await list({ prefix: "paging/", limit: 2, token }); |
| 176 | + expect(first.blobs.map((b) => b.pathname)).toEqual(["paging/1.txt", "paging/2.txt"]); |
| 177 | + expect(first.hasMore).toBe(true); |
| 178 | + expect(first.cursor).toBeDefined(); |
| 179 | + |
| 180 | + const second = await list({ prefix: "paging/", cursor: first.cursor, token }); |
| 181 | + expect(second.blobs.map((b) => b.pathname)).toEqual(["paging/3.txt"]); |
| 182 | + expect(second.hasMore).toBe(false); |
| 183 | + }); |
| 184 | + |
| 185 | + it("del removes a blob and head then rejects with BlobNotFoundError", async () => { |
| 186 | + const uploaded = await put("deletion/gone.txt", "bye", { access: "public", token }); |
| 187 | + await del(uploaded.url, { token }); |
| 188 | + |
| 189 | + await expect(head(uploaded.url, { token })).rejects.toBeInstanceOf(BlobNotFoundError); |
| 190 | + const res = await fetch(uploaded.url); |
| 191 | + expect(res.status).toBe(404); |
| 192 | + }); |
| 193 | + |
| 194 | + it("full blob URLs cannot target a different authenticated store", async () => { |
| 195 | + const otherToken = "vercel_blob_rw_otherstore_secret"; |
| 196 | + const own = await put("store-scope/shared.txt", "own", { access: "public", token }); |
| 197 | + const other = await put("store-scope/shared.txt", "other", { access: "public", token: otherToken }); |
| 198 | + |
| 199 | + await expect(head(other.url, { token })).rejects.toBeInstanceOf(BlobNotFoundError); |
| 200 | + |
| 201 | + await del(other.url, { token }); |
| 202 | + const ownRes = await fetch(own.url); |
| 203 | + const otherRes = await fetch(other.url); |
| 204 | + expect(ownRes.status).toBe(200); |
| 205 | + expect(await ownRes.text()).toBe("own"); |
| 206 | + expect(otherRes.status).toBe(200); |
| 207 | + expect(await otherRes.text()).toBe("other"); |
| 208 | + }); |
| 209 | + |
| 210 | + it("downloadUrl serves the content with an attachment disposition", async () => { |
| 211 | + const uploaded = await put("downloads/manual.txt", "download me", { access: "public", token }); |
| 212 | + |
| 213 | + const res = await fetch(uploaded.downloadUrl); |
| 214 | + expect(res.status).toBe(200); |
| 215 | + expect(res.headers.get("content-disposition")).toBe('attachment; filename="manual.txt"'); |
| 216 | + expect(await res.text()).toBe("download me"); |
| 217 | + |
| 218 | + // The plain url has no attachment disposition. |
| 219 | + const inline = await fetch(uploaded.url); |
| 220 | + expect(inline.headers.get("content-disposition")).toBeNull(); |
| 221 | + }); |
| 222 | + |
| 223 | + it("a bad token surfaces as BlobAccessError", async () => { |
| 224 | + await expect(list({ token: "not-a-blob-token" })).rejects.toBeInstanceOf(BlobAccessError); |
| 225 | + }); |
| 226 | +}); |
| 227 | + |
| 228 | +describe("Vercel Blob direct HTTP error shapes", () => { |
| 229 | + it("returns a 403 forbidden JSON body for a bad token", async () => { |
| 230 | + const res = await fetch(apiUrl, { headers: { authorization: "Bearer wrong" } }); |
| 231 | + expect(res.status).toBe(403); |
| 232 | + const body = (await res.json()) as { error: { code: string; message: string } }; |
| 233 | + expect(body.error.code).toBe("forbidden"); |
| 234 | + expect(typeof body.error.message).toBe("string"); |
| 235 | + }); |
| 236 | + |
| 237 | + it("returns a 404 not_found JSON body when head misses", async () => { |
| 238 | + const res = await fetch(`${apiUrl}?url=${encodeURIComponent("missing/never-uploaded.txt")}`, { |
| 239 | + headers: { authorization: `Bearer ${token}` }, |
| 240 | + }); |
| 241 | + expect(res.status).toBe(404); |
| 242 | + const body = (await res.json()) as { error: { code: string; message: string } }; |
| 243 | + expect(body.error.code).toBe("not_found"); |
| 244 | + expect(body.error.message).toBe("The requested blob does not exist"); |
| 245 | + }); |
| 246 | +}); |
0 commit comments