import { describe, it } from "node:test";
import { expect } from "@std/expect";
import { AZURE_LIMITS, AzureError, createAzureClient } from "../src/azure.ts";
import { createAzureDriver, createAzureDriverFromClient } from "../src/driver/azure.ts";
import { RequestCapture } from "./http.ts";
import { streamBytes } from "./stream.ts";
/** Creates one Azure-style XML response without coupling tests to an HTTP server. */
function xml(value: string, init: ResponseInit = {}): Response {
return new Response(value, {
status: 200,
headers: { "content-type": "application/xml", ...(init.headers ?? {}) },
...init,
});
}
/** Refreshable bearer source used to prove per-request token resolution. */
class BearerTokenSource {
/** Number of token requests observed. */
calls = 0;
/** Returns one token value that identifies its refresh call. */
get(): string {
this.calls += 1;
return `token-${this.calls}`;
}
}
describe("Azure Blob client", () => {
it("reports direct clients as owned and injected clients as borrowed", () => {
const options = {
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas" as const, token: "?sig=secret" },
fetch: async () => new Response(null, { status: 200 }),
};
const client = createAzureClient(options);
expect(createAzureDriver(options).inspect().ownership).toBe("owned");
expect(createAzureDriverFromClient(client).inspect().ownership).toBe("borrowed");
});
it("rejects invalid Azure metadata before provider I/O", async () => {
let requests = 0;
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
fetch: async () => {
requests += 1;
return new Response(null, { status: 201 });
},
});
for (const metadata of [
{ "bad-key": "value" },
{ valid_key: "caf\u00e9" },
{ Duplicate: "first", duplicate: "second" },
]) {
try {
await client.put("metadata.bin", new Uint8Array([1]), { metadata });
throw new Error("expected Azure metadata validation failure");
} catch (error) {
expect(error).toBeInstanceOf(TypeError);
}
}
expect(requests).toBe(0);
});
it("keeps SAS authorization on the source URL during provider-side copy", async () => {
const requests: Request[] = [];
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sv=2026-04-06&sig=secret" },
fetch: async (input, init) => {
const request = new Request(input, init);
requests.push(request);
if (request.method === "HEAD") {
return new Response(null, { status: 200, headers: { "content-length": "4", etag: '"etag"' } });
}
return new Response(null, { status: 202, headers: { "x-ms-copy-status": "success" } });
},
});
await client.copy!("source.txt", "copy.txt");
const copy = requests.find((request) => request.method === "PUT");
expect(copy?.headers.get("x-ms-copy-source")).toContain("/data/source.txt?");
expect(copy?.headers.get("x-ms-copy-source")).toContain("sig=secret");
expect(copy?.headers.get("x-ms-requires-sync")).toBe("true");
});
it("exposes behavior-changing Azure optimizations as independent switches", async () => {
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
blockUpload: false,
serverCopy: false,
fetch: async () => new Response(null, { status: 500 }),
});
expect(client.optimizations).toEqual({ blockUpload: false, serverCopy: false });
expect(client.capabilities.streamWrite).toBe(false);
expect(client.capabilities.copy).toBe(false);
await expect(client.put("stream.bin", streamBytes([new Uint8Array([1])]))).rejects.toThrow(TypeError);
await expect(client.copy!("source.bin", "copy.bin")).rejects.toThrow(TypeError);
});
it("uses Put Block From URL for blobs above the 256 MiB synchronous copy limit", async () => {
const requests: Request[] = [];
const size = 256 * 1024 * 1024 + 1;
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "bearer", token: "token" },
blockSize: 100 * 1024 * 1024,
fetch: async (input, init) => {
const request = new Request(input, init);
requests.push(request);
const url = new URL(request.url);
if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) {
return new Response(null, {
status: 200,
headers: { "content-length": String(size), etag: '"source-etag"' },
});
}
if (request.method === "HEAD") {
return new Response(null, { status: 200, headers: { "content-length": String(size), etag: '"copy-etag"' } });
}
return new Response(null, { status: 201 });
},
});
await client.copy!("source.bin", "copy.bin", { sourceIfMatch: '"source-etag"' });
const blocks = requests.filter((request) => new URL(request.url).searchParams.get("comp") === "block");
expect(blocks).toHaveLength(3);
expect(blocks[0]?.headers.get("x-ms-source-range")).toBe(`bytes=0-${100 * 1024 * 1024 - 1}`);
expect(blocks[0]?.headers.get("x-ms-copy-source-authorization")).toBe("Bearer token");
expect(blocks[0]?.headers.get("x-ms-source-if-match")).toBe('"source-etag"');
expect(requests.some((request) => new URL(request.url).searchParams.get("comp") === "blocklist")).toBe(true);
});
it("rejects direct server-side copy when the selected service version predates the API", async () => {
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
version: "2017-11-09",
fetch: async () => new Response(null, { status: 500 }),
});
await expect(client.copy!("source.bin", "copy.bin")).rejects.toBeInstanceOf(AzureError);
});
it("parses Azure list responses with prefixes and continuation markers", async () => {
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
fetch: async () =>
xml(`
root/a.txt
4
text/plain
"e"
root/nested/
next
`),
});
const page = await client.list({ prefix: "root/", delimiter: "/" });
expect(page.objects[0]?.key).toBe("root/a.txt");
expect(page.objects[0]?.mediaType).toBe("text/plain");
expect(page.prefixes).toEqual(["root/nested/"]);
expect(page.cursor).toBe("next");
});
it("retains Azure request IDs and service codes on failures", async () => {
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
fetch: async () =>
xml("AuthorizationFailuredenied", {
status: 403,
headers: { "x-ms-request-id": "request-1" },
}),
});
try {
await client.get("private.txt");
throw new Error("expected Azure failure");
} catch (error) {
expect(error).toBeInstanceOf(AzureError);
if (error instanceof AzureError) {
expect(error.code).toBe("AuthorizationFailure");
expect(error.requestId).toBe("request-1");
}
}
});
it("creates the documented Azurite Shared Key canonical signature", async () => {
let request: Request | undefined;
const client = createAzureClient({
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
container: "opfs-test",
credential: {
kind: "shared-key",
account: "devstoreaccount1",
key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
},
now: () => new Date("2026-08-14T12:00:00.000Z"),
fetch: async (input, init) => {
request = new Request(input, init);
return new Response(null, { status: 201 });
},
});
await client.request({ method: "PUT", query: { restype: "container" } });
expect(request?.headers.get("authorization")).toBe(
"SharedKey devstoreaccount1:h5gDRN/kZdrsO5FfUgKPNZGwb9UgFzqZvIDC5iVFi94=",
);
expect(request?.headers.get("x-ms-date")).toBe("Fri, 14 Aug 2026 12:00:00 GMT");
expect(request?.headers.get("content-length")).toBe("0");
});
it("rejects Shared Key service versions older than the implemented signing format", () => {
expect(() =>
createAzureClient({
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
container: "opfs-test",
credential: {
kind: "shared-key",
account: "devstoreaccount1",
key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
},
version: "2009-07-17",
})
).toThrow(RangeError);
});
it("signs zero Content-Length according to the selected Shared Key service version", async () => {
const oldCapture = new RequestCapture();
const modernCapture = new RequestCapture();
const credential = {
kind: "shared-key" as const,
account: "devstoreaccount1",
key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
};
const options = {
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
container: "opfs-test",
credential,
now: () => new Date("2026-08-14T12:00:00.000Z"),
};
await createAzureClient({ ...options, version: "2014-02-14", fetch: oldCapture.fetch.bind(oldCapture) }).request({
method: "PUT",
key: "zero.bin",
body: new Uint8Array(),
});
await createAzureClient({ ...options, version: "2015-02-21", fetch: modernCapture.fetch.bind(modernCapture) })
.request({
method: "PUT",
key: "zero.bin",
body: new Uint8Array(),
});
expect(oldCapture.latest?.headers.get("authorization")).toBe(
"SharedKey devstoreaccount1:l0m1mkwouin+1Fe6pBOf3LgSCgsrZMzD4luPiqfRonQ=",
);
expect(modernCapture.latest?.headers.get("authorization")).toBe(
"SharedKey devstoreaccount1:JHL00B0fQHliBPS7Gz7O2DcsyB3DwEnHjFUUxfTKvIY=",
);
});
it("omits empty x-ms headers before 2016-05-31 and signs them from that version onward", async () => {
const credential = {
kind: "shared-key" as const,
account: "devstoreaccount1",
key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
};
const base = {
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
container: "opfs-test",
credential,
now: () => new Date("2026-08-14T12:00:00.000Z"),
};
const legacyEmpty = new RequestCapture();
const legacyAbsent = new RequestCapture();
await createAzureClient({ ...base, version: "2015-02-21", fetch: legacyEmpty.fetch.bind(legacyEmpty) }).request({
method: "HEAD",
key: "value",
headers: { "x-ms-meta-empty": "" },
});
await createAzureClient({ ...base, version: "2015-02-21", fetch: legacyAbsent.fetch.bind(legacyAbsent) }).request({
method: "HEAD",
key: "value",
});
const modernEmpty = new RequestCapture();
const modernAbsent = new RequestCapture();
await createAzureClient({ ...base, version: "2016-05-31", fetch: modernEmpty.fetch.bind(modernEmpty) }).request({
method: "HEAD",
key: "value",
headers: { "x-ms-meta-empty": "" },
});
await createAzureClient({ ...base, version: "2016-05-31", fetch: modernAbsent.fetch.bind(modernAbsent) }).request({
method: "HEAD",
key: "value",
});
expect(legacyEmpty.latest?.headers.get("authorization")).toBe(legacyAbsent.latest?.headers.get("authorization"));
expect(modernEmpty.latest?.headers.get("authorization")).not.toBe(
modernAbsent.latest?.headers.get("authorization"),
);
});
it("validates block size against the selected Azure REST service version", () => {
expect(() =>
createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
version: "2015-04-05",
blockSize: AZURE_LIMITS.legacyBlockBytes + 1,
})
).toThrow(RangeError);
});
it("keeps destination conditions off Put Block and applies them at Put Block List", async () => {
const requests: Request[] = [];
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
blockSize: 4,
fetch: async (input, init) => {
const request = new Request(input, init);
requests.push(request);
if (request.method === "HEAD") {
return new Response(null, { status: 200, headers: { "content-length": "6", etag: '"done"' } });
}
return new Response(null, { status: 201 });
},
});
const body = streamBytes([new Uint8Array([1, 2, 3, 4, 5, 6])]);
await client.put("stream.bin", body, { ifNoneMatch: "*", size: 6 });
const blocks = requests.filter((request) => new URL(request.url).searchParams.get("comp") === "block");
const commit = requests.find((request) => new URL(request.url).searchParams.get("comp") === "blocklist");
expect(blocks.every((request) => !request.headers.has("if-none-match"))).toBe(true);
expect(commit?.headers.get("if-none-match")).toBe("*");
});
it("rejects source bearer copy authorization before service version 2020-10-02", async () => {
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "bearer", token: "token" },
version: "2019-12-12",
fetch: async (input, init) => {
const request = new Request(input, init);
if (request.method === "HEAD") {
return new Response(null, { status: 200, headers: { "content-length": "4", etag: '"source"' } });
}
return new Response(null, { status: 201 });
},
});
await expect(client.copy!("source.bin", "copy.bin")).rejects.toBeInstanceOf(AzureError);
});
it("canonicalizes Shared Key query fields independently from insertion order", async () => {
const first = new RequestCapture();
const second = new RequestCapture();
const credential = {
kind: "shared-key" as const,
account: "devstoreaccount1",
key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
};
const base = {
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
container: "opfs-test",
credential,
now: () => new Date("2026-08-14T12:00:00.000Z"),
};
await createAzureClient({ ...base, fetch: first.fetch.bind(first) }).request({
method: "GET",
query: { restype: "container", comp: "list", prefix: "root/" },
});
await createAzureClient({ ...base, fetch: second.fetch.bind(second) }).request({
method: "GET",
query: { prefix: "root/", comp: "list", restype: "container" },
});
expect(first.latest?.headers.get("authorization")).toBe(second.latest?.headers.get("authorization"));
});
it("collapses unquoted Shared Key whitespace without changing quoted-string whitespace", async () => {
const now = () => new Date("2026-08-14T12:00:00.000Z");
const credential = {
kind: "shared-key" as const,
account: "devstoreaccount1",
key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
};
const compact = new RequestCapture();
const spaced = new RequestCapture();
const quoted = new RequestCapture();
await createAzureClient({
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
container: "opfs-test",
credential,
now,
fetch: compact.fetch.bind(compact),
}).request({ method: "HEAD", key: "value", headers: { "x-ms-meta-note": 'alpha beta "two spaces"' } });
await createAzureClient({
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
container: "opfs-test",
credential,
now,
fetch: spaced.fetch.bind(spaced),
}).request({ method: "HEAD", key: "value", headers: { "x-ms-meta-note": 'alpha beta "two spaces"' } });
await createAzureClient({
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
container: "opfs-test",
credential,
now,
fetch: quoted.fetch.bind(quoted),
}).request({ method: "HEAD", key: "value", headers: { "x-ms-meta-note": 'alpha beta "two spaces"' } });
expect(compact.latest?.headers.get("authorization")).toBe(spaced.latest?.headers.get("authorization"));
expect(compact.latest?.headers.get("authorization")).not.toBe(quoted.latest?.headers.get("authorization"));
});
it("keeps HTTP evidence when a proxy returns a malformed non-Azure failure body", async () => {
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
fetch: async () =>
new Response("upstream gateway failed", {
status: 502,
headers: { "x-ms-request-id": "gateway-request" },
}),
});
try {
await client.get("state.bin");
throw new Error("expected gateway failure");
} catch (error) {
expect(error).toBeInstanceOf(AzureError);
if (error instanceof AzureError) {
expect(error.status).toBe(502);
expect(error.requestId).toBe("gateway-request");
expect(error.code).toBeUndefined();
expect(error.message).toContain("HTTP 502");
}
}
});
it("uses SAS query authorization without adding an Authorization header", async () => {
const capture = new RequestCapture();
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sv=2026-04-06&sig=secret" },
fetch: capture.fetch.bind(capture),
});
await client.request({ method: "HEAD", key: "state.bin" });
expect(capture.latest?.headers.has("authorization")).toBe(false);
expect(new URL(capture.latest!.url).searchParams.get("sig")).toBe("secret");
});
it("resolves a bearer token immediately before every request", async () => {
const token = new BearerTokenSource();
const capture = new RequestCapture();
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "bearer", token: token.get.bind(token) },
fetch: capture.fetch.bind(capture),
});
await client.request({ method: "HEAD", key: "one" });
await client.request({ method: "HEAD", key: "two" });
expect(token.calls).toBe(2);
expect(capture.requests[0]?.headers.get("authorization")).toBe("Bearer token-1");
expect(capture.requests[1]?.headers.get("authorization")).toBe("Bearer token-2");
});
it("does not advertise server-side copy for caller-defined authorization headers", () => {
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "headers", get: () => ({ authorization: "Provider token" }) },
});
expect(client.capabilities.copy).toBe(false);
});
it("expands a known streamed block size to stay within 50,000 committed blocks", async () => {
const requests: Request[] = [];
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
blockSize: 1,
fetch: async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(null, { status: 201 });
},
});
await expect(client.put(
"planned.bin",
streamBytes([new Uint8Array([1, 2, 3, 4])]),
{ size: AZURE_LIMITS.maxCommittedBlocks + 1 },
)).rejects.toThrow(RangeError);
const blocks = requests.filter((request) => new URL(request.url).searchParams.get("comp") === "block");
expect(blocks).toHaveLength(2);
expect(blocks[0]?.headers.get("content-length")).toBe("2");
expect(blocks[1]?.headers.get("content-length")).toBe("2");
expect(requests.some((request) => new URL(request.url).searchParams.get("comp") === "blocklist")).toBe(false);
});
it("rejects a declared blob larger than the selected service-version block plan before Fetch", async () => {
let fetches = 0;
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "data",
credential: { kind: "sas", token: "?sig=secret" },
fetch: async () => {
fetches += 1;
return new Response(null, { status: 500 });
},
});
const max = AZURE_LIMITS.currentBlockBytes * AZURE_LIMITS.maxCommittedBlocks;
await expect(client.put(
"too-large.bin",
streamBytes([new Uint8Array([1])]),
{ size: max + 1 },
)).rejects.toThrow(RangeError);
expect(fetches).toBe(0);
});
});
describe("Azure request policy", () => {
it("retries replayable 503 responses and rebuilds authorization per attempt", async () => {
let attempts = 0;
let authCalls = 0;
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "container",
credential: {
kind: "headers",
get: () => ({ authorization: `test-${++authCalls}` }),
},
request: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
fetch: async (_input, init) => {
attempts += 1;
expect(init?.redirect).toBe("manual");
return new Response(null, { status: attempts === 1 ? 503 : 200 });
},
});
const response = await client.request({ method: "PUT", key: "retry.bin", body: new Uint8Array([1]) });
expect(response.status).toBe(200);
expect(attempts).toBe(2);
expect(authCalls).toBe(2);
expect(client.getMetrics().retries).toBe(1);
});
it("retries a replayable Fetch transport failure and returns the next response", async () => {
let attempts = 0;
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "container",
credential: { kind: "sas", token: "sv=test&sig=test" },
request: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
fetch: async () => {
attempts += 1;
if (attempts === 1) throw new TypeError("temporary network failure");
return new Response(null, { status: 200 });
},
});
const response = await client.request({ method: "GET", key: "retry-network.bin" });
expect(response.status).toBe(200);
expect(attempts).toBe(2);
expect(client.getMetrics().retries).toBe(1);
expect(client.getMetrics().failures).toBe(0);
});
it("does not retry deterministic authorization failures", async () => {
let authCalls = 0;
let fetches = 0;
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "container",
credential: {
kind: "headers",
get: () => {
authCalls += 1;
throw new TypeError("invalid authorization input");
},
},
request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
fetch: async () => {
fetches += 1;
return new Response(null, { status: 200 });
},
});
await expect(client.request({ method: "GET", key: "key" })).rejects.toThrow("invalid authorization input");
expect(authCalls).toBe(1);
expect(fetches).toBe(0);
});
it("lets a low-level caller disable retry for a replayable request", async () => {
let attempts = 0;
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "container",
credential: { kind: "sas", token: "sv=test&sig=test" },
request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
fetch: async () => {
attempts += 1;
return new Response(null, { status: 503 });
},
});
const response = await client.request({ method: "PUT", key: "once.bin", body: new Uint8Array([1]), retry: false });
expect(response.status).toBe(503);
expect(attempts).toBe(1);
});
it("does not retry a one-shot streamed request body", async () => {
let attempts = 0;
const client = createAzureClient({
endpoint: "https://account.blob.core.windows.net",
container: "container",
credential: { kind: "sas", token: "sv=test&sig=test" },
request: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
fetch: async () => {
attempts += 1;
return new Response(null, { status: 503 });
},
});
const body = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1]));
controller.close();
},
});
const response = await client.request({ method: "PUT", key: "stream.bin", body });
expect(response.status).toBe(503);
expect(attempts).toBe(1);
});
});