tinyrelayfile
tinyrelay/scripts/conformance/blossom.test.ts/

scripts/conformance/blossom.test.ts raw
1// Files, from outside: a Blossom upload, the download with and without a
2// range, the listing and the delete. The one door that touches the media
3// bucket, so a host whose bucket is not R2 (docs/16) is checked here.
4import { describe, it, expect } from "vitest";
5import { sha256 } from "@noble/hashes/sha2.js";
6import { newEvent, newKey, pub, now, rand, hex, HTTP_URL } from "./helpers.ts";
7
8// A Blossom token: kind 24242 with the verb and the hash, base64 in the header.
9function token(sk: Uint8Array, verb: "upload" | "get" | "list" | "delete", sha?: string): string {
10 const tags = [["t", verb], ...(sha ? [["x", sha]] : []), ["expiration", String(now() + 300)]];
11 return "Nostr " + Buffer.from(JSON.stringify(newEvent(sk, 24242, verb, tags))).toString("base64");
12}
13
14describe("Blossom media", () => {
15 it("advertises the BUD-06 browser and upload preflight contract", async () => {
16 const preflight = await fetch(`${HTTP_URL}/upload`, {
17 method: "OPTIONS",
18 headers: {
19 origin: "https://client.example",
20 "access-control-request-method": "PUT",
21 "access-control-request-headers": "authorization, content-type, x-content-length, x-content-type",
22 },
23 });
24 expect(preflight.status).toBe(204);
25 expect(preflight.headers.get("access-control-allow-origin")).toBe("*");
26 expect(preflight.headers.get("access-control-allow-methods")).toContain("PUT");
27 expect(preflight.headers.get("access-control-allow-headers")?.toLowerCase()).toContain("x-content-type");
28
29 const sk = newKey();
30 const body = new TextEncoder().encode(`preflight ${rand()}`);
31 const sha = hex(sha256(body));
32 const head = await fetch(`${HTTP_URL}/upload`, {
33 method: "HEAD",
34 headers: {
35 authorization: token(sk, "upload", sha),
36 "x-sha-256": sha,
37 "x-content-length": String(body.length),
38 "x-content-type": "text/plain",
39 },
40 });
41 expect(head.status).toBe(200);
42 expect(await head.text()).toBe("");
43 });
44
45 it("accepts a BUD-13 hash-addressed upload and rejects a mismatched hash", async () => {
46 const sk = newKey();
47 const body = new TextEncoder().encode(`path upload ${rand()}`);
48 const sha = hex(sha256(body));
49 const upload = await fetch(`${HTTP_URL}/${sha}.txt`, {
50 method: "PUT",
51 headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" },
52 body,
53 });
54 expect(upload.status, await upload.clone().text()).toBe(201);
55 expect((await upload.json<any>()).sha256).toBe(sha);
56
57 const mismatchHash = "0".repeat(64);
58 const mismatch = await fetch(`${HTTP_URL}/${mismatchHash}`, {
59 method: "PUT",
60 headers: { authorization: token(sk, "upload", mismatchHash), "content-type": "text/plain" },
61 body,
62 });
63 expect(mismatch.status).toBe(409);
64
65 const del = await fetch(`${HTTP_URL}/${sha}`, {
66 method: "DELETE",
67 headers: { authorization: token(sk, "delete", sha) },
68 });
69 expect(del.status).toBe(204);
70 });
71
72 it("advertises and completes BUD-14 uploads out of order with overlap", async () => {
73 const sk = newKey();
74 const body = new TextEncoder().encode(`bud14 ${rand()} multipart body`);
75 const sha = hex(sha256(body));
76 const split = Math.floor(body.length / 2);
77
78 const preflight = await fetch(`${HTTP_URL}/${sha}.bin`, {
79 method: "OPTIONS",
80 headers: {
81 origin: "https://client.example",
82 "access-control-request-method": "PATCH",
83 "access-control-request-headers": "authorization, content-type, upload-type, upload-length, upload-offset",
84 },
85 });
86 expect(preflight.status).toBe(204);
87 expect(preflight.headers.get("allow")).toContain("PATCH");
88 expect(preflight.headers.get("access-control-allow-origin")).toBe("*");
89 expect(preflight.headers.get("access-control-allow-methods")).toContain("PATCH");
90
91 // The authorization is scoped to the final hash. It is reused for both
92 // chunks; individual chunk hashes must not be required by BUD-14.
93 const auth = token(sk, "upload", sha);
94 const secondOffset = Math.max(1, split - 2);
95 const second = body.slice(secondOffset);
96 const first = body.slice(0, split + 2);
97 const send = (offset: number, chunk: Uint8Array) => fetch(`${HTTP_URL}/${sha}.bin`, {
98 method: "PATCH",
99 headers: {
100 authorization: auth,
101 "content-type": "application/octet-stream",
102 "upload-type": "text/plain",
103 "upload-length": String(body.length),
104 "upload-offset": String(offset),
105 "content-length": String(chunk.length),
106 },
107 body: chunk,
108 });
109
110 const chunkScoped = await fetch(`${HTTP_URL}/${sha}.bin`, {
111 method: "PATCH",
112 headers: {
113 authorization: token(sk, "upload", hex(sha256(second))),
114 "content-type": "application/octet-stream",
115 "upload-type": "text/plain",
116 "upload-length": String(body.length),
117 "upload-offset": String(secondOffset),
118 "content-length": String(second.length),
119 },
120 body: second,
121 });
122 expect(chunkScoped.status).toBe(401);
123
124 // Send the later range first, then an overlapping earlier range.
125 const later = await send(secondOffset, second);
126 expect(later.status).toBe(204);
127 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(404);
128 const complete = await send(0, first);
129 expect(complete.status, await complete.clone().text()).toBe(201);
130 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(200);
131 expect(new Uint8Array(await (await fetch(`${HTTP_URL}/${sha}`)).arrayBuffer())).toEqual(body);
132
133 const del = await fetch(`${HTTP_URL}/${sha}`, {
134 method: "DELETE",
135 headers: { authorization: token(sk, "delete", sha) },
136 });
137 expect(del.status).toBe(204);
138 });
139
140 it("rejects a complete BUD-14 upload whose reconstructed bytes have the wrong hash", async () => {
141 const sk = newKey();
142 const body = new TextEncoder().encode(`bud14 mismatch ${rand()}`);
143 const claimed = hex(sha256(new TextEncoder().encode(`${body} wrong`)));
144 const auth = token(sk, "upload", claimed);
145 const response = await fetch(`${HTTP_URL}/${claimed}`, {
146 method: "PATCH",
147 headers: {
148 authorization: auth,
149 "content-type": "application/octet-stream",
150 "upload-type": "text/plain",
151 "upload-length": String(body.length),
152 "upload-offset": "0",
153 "content-length": String(body.length),
154 },
155 body,
156 });
157 expect(response.status).toBe(409);
158 expect((await fetch(`${HTTP_URL}/${claimed}`)).status).toBe(404);
159 });
160
161 it("keeps repeated BUD-14 ranges idempotent while an upload is partial", async () => {
162 const sk = newKey();
163 const body = new TextEncoder().encode(`bud14 duplicate ${rand()}`);
164 const sha = hex(sha256(body));
165 const auth = token(sk, "upload", sha);
166 const send = (offset: number, chunk: Uint8Array) => fetch(`${HTTP_URL}/${sha}`, {
167 method: "PATCH",
168 headers: {
169 authorization: auth,
170 "content-type": "application/octet-stream",
171 "upload-type": "text/plain",
172 "upload-length": String(body.length),
173 "upload-offset": String(offset),
174 "content-length": String(chunk.length),
175 },
176 body: chunk,
177 });
178 const first = body.slice(0, Math.floor(body.length / 2));
179 expect((await send(0, first)).status).toBe(204);
180 expect((await send(0, first)).status).toBe(204);
181 const finish = await send(first.length, body.slice(first.length));
182 expect(finish.status, await finish.clone().text()).toBe(201);
183 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(200);
184 expect((await fetch(`${HTTP_URL}/${sha}`, {
185 method: "DELETE",
186 headers: { authorization: token(sk, "delete", sha) },
187 })).status).toBe(204);
188 });
189
190 it("uploads a file, serves it whole and by range, lists it, and deletes it", async () => {
191 const sk = newKey();
192 const body = new TextEncoder().encode(`a small file ${rand()} with enough bytes for a range`);
193 const sha = hex(sha256(body));
194
195 const put = await fetch(`${HTTP_URL}/upload`, { method: "PUT", headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" }, body });
196 expect(put.status, await put.clone().text()).toBe(200);
197 const desc: any = await put.json();
198 expect([desc.sha256, desc.size, desc.type]).toEqual([sha, body.length, "text/plain"]);
199 expect(desc.url).toMatch(new RegExp(`/${sha}\\.txt$`));
200
201 // The whole file, with its type and hash-derived etag.
202 const get = await fetch(`${HTTP_URL}/${sha}.txt`);
203 expect(get.status).toBe(200);
204 expect(get.headers.get("content-type")).toBe("text/plain");
205 expect(new Uint8Array(await get.arrayBuffer())).toEqual(body);
206
207 // A byte range: the second word.
208 const part = await fetch(`${HTTP_URL}/${sha}`, { headers: { range: "bytes=2-6" } });
209 expect(part.status).toBe(206);
210 expect(part.headers.get("content-range")).toBe(`bytes 2-6/${body.length}`);
211 expect(await part.text()).toBe("small");
212
213 // HEAD says it is there without a body.
214 const head = await fetch(`${HTTP_URL}/${sha}`, { method: "HEAD" });
215 expect(head.status).toBe(200);
216 expect(await head.text()).toBe("");
217
218 // The uploader's listing carries it.
219 const list = await fetch(`${HTTP_URL}/list/${pub(sk)}`, { headers: { authorization: token(sk, "list") } });
220 expect(list.status).toBe(200);
221 expect((await list.json<any>()).map((d: any) => d.sha256)).toContain(sha);
222
223 // The uploader deletes it; the same bytes can come back afterwards.
224 const del = await fetch(`${HTTP_URL}/${sha}`, { method: "DELETE", headers: { authorization: token(sk, "delete", sha) } });
225 expect(del.status).toBe(204);
226 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(404);
227 const again = await fetch(`${HTTP_URL}/upload`, { method: "PUT", headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" }, body });
228 expect(again.status).toBe(200);
229 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(200);
230 await fetch(`${HTTP_URL}/${sha}`, { method: "DELETE", headers: { authorization: token(sk, "delete", sha) } });
231 });
232});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs