blossom_http_test.go raw
1 package blossom
2
3 import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "git.smesh.lol/torly/pkg/nostr"
16 )
17
18 func testKey(seed byte) [32]byte {
19 var sk [32]byte
20 sk[31] = seed
21 return sk
22 }
23
24 func authEvent(t *testing.T, sk [32]byte, kind int, action, hash string, created, expires int64) string {
25 t.Helper()
26 tags := nostr.Tags{}
27 if action != "" {
28 tags = append(tags, nostr.Tag{"t", action})
29 }
30 if hash != "" {
31 tags = append(tags, nostr.Tag{"x", hash})
32 }
33 if expires != 0 {
34 tags = append(tags, nostr.Tag{"expiration", fmt.Sprint(expires)})
35 }
36 ev := &nostr.Event{Kind: kind, CreatedAt: created, Tags: tags}
37 if err := ev.Sign(sk); err != nil {
38 t.Fatal(err)
39 }
40 return "Nostr " + base64.StdEncoding.EncodeToString(ev.AppendJSON(nil))
41 }
42
43 func TestVerifyAuth(t *testing.T) {
44 now := time.Now().Unix()
45 hash := strings.Repeat("ab", 32)
46 pub, _ := nostr.PubKeyHex(testKey(1))
47 valid := authEvent(t, testKey(1), 24242, "upload", hash, now, now+300)
48
49 got, err := verifyAuth(valid, "upload", hash, now)
50 if err != nil {
51 t.Fatalf("valid auth rejected: %v", err)
52 }
53 if nostr.Hex(got[:]) != pub {
54 t.Fatalf("pubkey mismatch: %x", got)
55 }
56
57 // A second key signs the negative cases so signatures stay valid.
58 badSig := authEvent(t, testKey(2), 24242, "upload", hash, now, now+300)
59 raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(badSig, "Nostr "))
60 ev2, _ := nostr.ParseEvent(raw)
61 ev2.Content = "tampered"
62 badSig = "Nostr " + base64.StdEncoding.EncodeToString(ev2.AppendJSON(nil))
63
64 cases := []struct {
65 name string
66 header string
67 action string
68 hash string
69 }{
70 {"missing", "", "upload", hash},
71 {"wrong scheme", "Bearer abc", "upload", hash},
72 {"bad base64", "Nostr !!!!", "upload", hash},
73 {"wrong kind", authEvent(t, testKey(1), 1, "upload", hash, now, now+300), "upload", hash},
74 {"expired", authEvent(t, testKey(1), 24242, "upload", hash, now, now-1), "upload", hash},
75 {"no expiration", authEvent(t, testKey(1), 24242, "upload", hash, now, 0), "upload", hash},
76 {"wrong action", valid, "delete", hash},
77 {"wrong hash", valid, "upload", strings.Repeat("cd", 32)},
78 {"stale timestamp", authEvent(t, testKey(1), 24242, "upload", hash, now-3600, now+300), "upload", hash},
79 {"future timestamp", authEvent(t, testKey(1), 24242, "upload", hash, now+3600, now+7200), "upload", hash},
80 {"bad signature", badSig, "upload", hash},
81 }
82 for _, tc := range cases {
83 t.Run(tc.name, func(t *testing.T) {
84 if _, err := verifyAuth(tc.header, tc.action, tc.hash, now); err == nil {
85 t.Fatal("auth accepted")
86 }
87 })
88 }
89 }
90
91 var currentServer *Server
92
93 func startBlossomServer(t *testing.T) (*Server, string) {
94 t.Helper()
95 s, err := Open(filepath.Join(t.TempDir(), "blossom"), 4<<20)
96 if err != nil {
97 t.Fatal(err)
98 }
99 currentServer = s
100 t.Cleanup(func() { s.Close() })
101 return s, ""
102 }
103 func httpDo(t *testing.T, method, path, auth string, body []byte, headers map[string]string) (int, []byte, [][2]string) {
104 t.Helper()
105 h := make(map[string]string, len(headers)+1)
106 for k, v := range headers {
107 h[strings.ToLower(k)] = v
108 }
109 if auth != "" {
110 h["authorization"] = auth
111 }
112 srv := currentServer
113 resp, handled := srv.Serve(&Request{Method: method, Path: path, Header: h, Body: body})
114 if !handled {
115 return 404, nil, nil
116 }
117 return resp.Status, resp.Body, resp.Header
118 }
119
120 func headerValue(h [][2]string, name string) string {
121 for _, kv := range h {
122 if strings.EqualFold(kv[0], name) {
123 return kv[1]
124 }
125 }
126 return ""
127 }
128 func TestBlossomHTTPLifecycle(t *testing.T) {
129 startBlossomServer(t)
130 now := time.Now().Unix()
131 body := []byte("blossom lifecycle payload")
132 sum := sha256.Sum256(body)
133 hash := hex.EncodeToString(sum[:])
134 owner := testKey(1)
135 other := testKey(2)
136 base := ""
137
138 uploadAuth := authEvent(t, owner, 24242, "upload", hash, now, now+300)
139
140 // Authentication is mandatory.
141 code, _, _ := httpDo(t, "PUT", base+"/upload", "", body, nil)
142 if code != 401 {
143 t.Fatalf("unauthenticated upload: %d", code)
144 }
145 code, _, _ = httpDo(t, "PUT", base+"/upload", authEvent(t, owner, 24242, "upload", strings.Repeat("cd", 32), now, now+300), body, nil)
146 if code != 401 {
147 t.Fatalf("mismatched x tag: %d", code)
148 }
149
150 // Upload and its descriptor.
151 code, data, _ := httpDo(t, "PUT", base+"/upload", uploadAuth, body, map[string]string{"Content-Type": "text/plain"})
152 if code != 200 {
153 t.Fatalf("upload: %d %s", code, data)
154 }
155 var desc Descriptor
156 if err := json.Unmarshal(data, &desc); err != nil {
157 t.Fatal(err)
158 }
159 if desc.SHA256 != hash || desc.Size != int64(len(body)) || desc.Type != "text/plain" {
160 t.Fatalf("descriptor: %+v", desc)
161 }
162
163 // Re-uploading the same bytes is idempotent.
164 code, data, _ = httpDo(t, "PUT", base+"/upload", uploadAuth, body, map[string]string{"Content-Type": "text/plain"})
165 if code != 200 || !strings.Contains(string(data), hash) {
166 t.Fatalf("duplicate upload: %d %s", code, data)
167 }
168
169 // GET and HEAD.
170 code, got, headers := httpDo(t, "GET", base+"/"+hash, "", nil, nil)
171 if code != 200 || !bytes.Equal(got, body) {
172 t.Fatalf("get: %d %q", code, got)
173 }
174 if headerValue(headers, "Content-Type") != "text/plain" || headerValue(headers, "ETag") != `"`+hash+`"` {
175 t.Fatalf("get headers: %v", headers)
176 }
177 code, got, headers = httpDo(t, "HEAD", base+"/"+hash, "", nil, nil)
178 if code != 200 || len(got) != 0 {
179 t.Fatalf("head: %d body %q", code, got)
180 }
181 if headerValue(headers, "Content-Length") != fmt.Sprint(len(body)) {
182 t.Fatalf("head content-length: %q", headerValue(headers, "Content-Length"))
183 }
184
185 // Upload check (BUD-06).
186 code, _, _ = httpDo(t, "HEAD", base+"/upload", uploadAuth, nil, map[string]string{"X-SHA-256": hash})
187 if code != 200 {
188 t.Fatalf("upload check existing: %d", code)
189 }
190 missingHash := strings.Repeat("ef", 32)
191 missingAuth := authEvent(t, owner, 24242, "upload", missingHash, now, now+300)
192 code, _, _ = httpDo(t, "HEAD", base+"/upload", missingAuth, nil, map[string]string{"X-SHA-256": missingHash})
193 if code != 404 {
194 t.Fatalf("upload check missing: %d", code)
195 }
196
197 // Listing: public by default, owner-only when authenticated.
198 code, data, _ = httpDo(t, "GET", base+"/list/"+nostr.Hex(mustPub(t, owner)[:]), "", nil, nil)
199 if code != 200 || !strings.Contains(string(data), hash) {
200 t.Fatalf("public list: %d %s", code, data)
201 }
202 listAuth := authEvent(t, other, 24242, "list", "", now, now+300)
203 code, _, _ = httpDo(t, "GET", base+"/list/"+nostr.Hex(mustPub(t, owner)[:]), listAuth, nil, nil)
204 if code != 403 {
205 t.Fatalf("foreign list: %d", code)
206 }
207 ownList := authEvent(t, owner, 24242, "list", "", now, now+300)
208 code, _, _ = httpDo(t, "GET", base+"/list/"+nostr.Hex(mustPub(t, owner)[:]), ownList, nil, nil)
209 if code != 200 {
210 t.Fatalf("owner list: %d", code)
211 }
212
213 // Deletion is owner-only.
214 code, _, _ = httpDo(t, "DELETE", base+"/"+hash, "", nil, nil)
215 if code != 401 {
216 t.Fatalf("unauthenticated delete: %d", code)
217 }
218 code, _, _ = httpDo(t, "DELETE", base+"/"+hash, authEvent(t, other, 24242, "delete", hash, now, now+300), nil, nil)
219 if code != 403 {
220 t.Fatalf("foreign delete: %d", code)
221 }
222 code, _, _ = httpDo(t, "DELETE", base+"/"+hash, authEvent(t, owner, 24242, "delete", hash, now, now+300), nil, nil)
223 if code != 204 {
224 t.Fatalf("owner delete: %d", code)
225 }
226 code, _, _ = httpDo(t, "GET", base+"/"+hash, "", nil, nil)
227 if code != 404 {
228 t.Fatalf("blob after delete: %d", code)
229 }
230
231 // Methods and paths.
232 code, _, _ = httpDo(t, "POST", base+"/upload", uploadAuth, body, nil)
233 if code != 405 {
234 t.Fatalf("POST /upload: %d", code)
235 }
236 code, _, _ = httpDo(t, "GET", base+"/"+strings.Repeat("ab", 32), "", nil, nil)
237 if code != 404 {
238 t.Fatalf("missing blob: %d", code)
239 }
240 code, _, _ = httpDo(t, "GET", base+"/notahash", "", nil, nil)
241 if code != 404 {
242 t.Fatalf("non-blossom path: %d", code)
243 }
244 }
245
246 func mustPub(t *testing.T, sk [32]byte) []byte {
247 t.Helper()
248 pub, err := nostr.PubKeyHex(sk)
249 if err != nil {
250 t.Fatal(err)
251 }
252 var out [32]byte
253 nostr.DecodeRaw(pub, out[:])
254 return out[:]
255 }
256
257 func TestBlossomMountAndCORS(t *testing.T) {
258 startBlossomServer(t)
259 now := time.Now().Unix()
260 body := []byte("mounted blossom payload")
261 sum := sha256.Sum256(body)
262 hash := hex.EncodeToString(sum[:])
263 owner := testKey(1)
264 uploadAuth := authEvent(t, owner, 24242, "upload", hash, now, now+300)
265
266 code, data, _ := httpDo(t, "OPTIONS", "/blossom/upload", "", nil, map[string]string{
267 "Origin": "https://smesh.mleku.dev",
268 "Access-Control-Request-Method": "PUT",
269 "Access-Control-Request-Headers": "authorization,content-type",
270 })
271 if code != 204 {
272 t.Fatalf("preflight: %d %s", code, data)
273 }
274
275 code, data, _ = httpDo(t, "PUT", "/blossom/upload", uploadAuth, body, map[string]string{"Content-Type": "text/plain"})
276 if code != 200 {
277 t.Fatalf("mounted upload: %d %s", code, data)
278 }
279 code, got, _ := httpDo(t, "GET", "/blossom/"+hash+".txt", "", nil, nil)
280 if code != 200 || !bytes.Equal(got, body) {
281 t.Fatalf("mounted get with extension: %d %q", code, got)
282 }
283 code, data, _ = httpDo(t, "GET", "/blossom/list/"+nostr.Hex(mustPub(t, owner)[:]), "", nil, nil)
284 if code != 200 || !strings.Contains(string(data), hash) {
285 t.Fatalf("mounted list: %d %s", code, data)
286 }
287 }
288
289 func TestDescriptorURLHasExtension(t *testing.T) {
290 startBlossomServer(t)
291 now := time.Now().Unix()
292 body := []byte("jpeg-ish payload")
293 sum := sha256.Sum256(body)
294 hash := hex.EncodeToString(sum[:])
295 owner := testKey(1)
296 uploadAuth := authEvent(t, owner, 24242, "upload", hash, now, now+300)
297
298 code, data, _ := httpDo(t, "PUT", "/blossom/upload", uploadAuth, body, map[string]string{"Content-Type": "image/jpeg"})
299 if code != 200 {
300 t.Fatalf("upload: %d %s", code, data)
301 }
302 var desc Descriptor
303 if err := json.Unmarshal(data, &desc); err != nil {
304 t.Fatal(err)
305 }
306 if !strings.HasSuffix(desc.URL, "/"+hash+".jpg") {
307 t.Fatalf("descriptor URL missing extension: %s", desc.URL)
308 }
309 code, got, headers := httpDo(t, "GET", "/blossom/"+hash+".jpg", "", nil, nil)
310 if code != 200 || !bytes.Equal(got, body) {
311 t.Fatalf("extended GET: %d %q", code, got)
312 }
313 if headerValue(headers, "Content-Type") != "image/jpeg" {
314 t.Fatalf("content type: %v", headers)
315 }
316 }
317
318 func TestExtensionForType(t *testing.T) {
319 cases := map[string]string{
320 "image/jpeg": ".jpg",
321 "image/jpeg; charset=utf": ".jpg",
322 "image/png": ".png",
323 "video/mp4": ".mp4",
324 "application/octet-stream": "",
325 "": "",
326 }
327 for in, want := range cases {
328 if got := extensionForType(in); got != want {
329 t.Errorf("extensionForType(%q) = %q, want %q", in, got, want)
330 }
331 }
332 }
333