package blossom import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "path/filepath" "strings" "testing" "time" "git.smesh.lol/torly/pkg/nostr" ) func testKey(seed byte) [32]byte { var sk [32]byte sk[31] = seed return sk } func authEvent(t *testing.T, sk [32]byte, kind int, action, hash string, created, expires int64) string { t.Helper() tags := nostr.Tags{} if action != "" { tags = append(tags, nostr.Tag{"t", action}) } if hash != "" { tags = append(tags, nostr.Tag{"x", hash}) } if expires != 0 { tags = append(tags, nostr.Tag{"expiration", fmt.Sprint(expires)}) } ev := &nostr.Event{Kind: kind, CreatedAt: created, Tags: tags} if err := ev.Sign(sk); err != nil { t.Fatal(err) } return "Nostr " + base64.StdEncoding.EncodeToString(ev.AppendJSON(nil)) } func TestVerifyAuth(t *testing.T) { now := time.Now().Unix() hash := strings.Repeat("ab", 32) pub, _ := nostr.PubKeyHex(testKey(1)) valid := authEvent(t, testKey(1), 24242, "upload", hash, now, now+300) got, err := verifyAuth(valid, "upload", hash, now) if err != nil { t.Fatalf("valid auth rejected: %v", err) } if nostr.Hex(got[:]) != pub { t.Fatalf("pubkey mismatch: %x", got) } // A second key signs the negative cases so signatures stay valid. badSig := authEvent(t, testKey(2), 24242, "upload", hash, now, now+300) raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(badSig, "Nostr ")) ev2, _ := nostr.ParseEvent(raw) ev2.Content = "tampered" badSig = "Nostr " + base64.StdEncoding.EncodeToString(ev2.AppendJSON(nil)) cases := []struct { name string header string action string hash string }{ {"missing", "", "upload", hash}, {"wrong scheme", "Bearer abc", "upload", hash}, {"bad base64", "Nostr !!!!", "upload", hash}, {"wrong kind", authEvent(t, testKey(1), 1, "upload", hash, now, now+300), "upload", hash}, {"expired", authEvent(t, testKey(1), 24242, "upload", hash, now, now-1), "upload", hash}, {"no expiration", authEvent(t, testKey(1), 24242, "upload", hash, now, 0), "upload", hash}, {"wrong action", valid, "delete", hash}, {"wrong hash", valid, "upload", strings.Repeat("cd", 32)}, {"stale timestamp", authEvent(t, testKey(1), 24242, "upload", hash, now-3600, now+300), "upload", hash}, {"future timestamp", authEvent(t, testKey(1), 24242, "upload", hash, now+3600, now+7200), "upload", hash}, {"bad signature", badSig, "upload", hash}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if _, err := verifyAuth(tc.header, tc.action, tc.hash, now); err == nil { t.Fatal("auth accepted") } }) } } var currentServer *Server func startBlossomServer(t *testing.T) (*Server, string) { t.Helper() s, err := Open(filepath.Join(t.TempDir(), "blossom"), 4<<20) if err != nil { t.Fatal(err) } currentServer = s t.Cleanup(func() { s.Close() }) return s, "" } func httpDo(t *testing.T, method, path, auth string, body []byte, headers map[string]string) (int, []byte, [][2]string) { t.Helper() h := make(map[string]string, len(headers)+1) for k, v := range headers { h[strings.ToLower(k)] = v } if auth != "" { h["authorization"] = auth } srv := currentServer resp, handled := srv.Serve(&Request{Method: method, Path: path, Header: h, Body: body}) if !handled { return 404, nil, nil } return resp.Status, resp.Body, resp.Header } func headerValue(h [][2]string, name string) string { for _, kv := range h { if strings.EqualFold(kv[0], name) { return kv[1] } } return "" } func TestBlossomHTTPLifecycle(t *testing.T) { startBlossomServer(t) now := time.Now().Unix() body := []byte("blossom lifecycle payload") sum := sha256.Sum256(body) hash := hex.EncodeToString(sum[:]) owner := testKey(1) other := testKey(2) base := "" uploadAuth := authEvent(t, owner, 24242, "upload", hash, now, now+300) // Authentication is mandatory. code, _, _ := httpDo(t, "PUT", base+"/upload", "", body, nil) if code != 401 { t.Fatalf("unauthenticated upload: %d", code) } code, _, _ = httpDo(t, "PUT", base+"/upload", authEvent(t, owner, 24242, "upload", strings.Repeat("cd", 32), now, now+300), body, nil) if code != 401 { t.Fatalf("mismatched x tag: %d", code) } // Upload and its descriptor. code, data, _ := httpDo(t, "PUT", base+"/upload", uploadAuth, body, map[string]string{"Content-Type": "text/plain"}) if code != 200 { t.Fatalf("upload: %d %s", code, data) } var desc Descriptor if err := json.Unmarshal(data, &desc); err != nil { t.Fatal(err) } if desc.SHA256 != hash || desc.Size != int64(len(body)) || desc.Type != "text/plain" { t.Fatalf("descriptor: %+v", desc) } // Re-uploading the same bytes is idempotent. code, data, _ = httpDo(t, "PUT", base+"/upload", uploadAuth, body, map[string]string{"Content-Type": "text/plain"}) if code != 200 || !strings.Contains(string(data), hash) { t.Fatalf("duplicate upload: %d %s", code, data) } // GET and HEAD. code, got, headers := httpDo(t, "GET", base+"/"+hash, "", nil, nil) if code != 200 || !bytes.Equal(got, body) { t.Fatalf("get: %d %q", code, got) } if headerValue(headers, "Content-Type") != "text/plain" || headerValue(headers, "ETag") != `"`+hash+`"` { t.Fatalf("get headers: %v", headers) } code, got, headers = httpDo(t, "HEAD", base+"/"+hash, "", nil, nil) if code != 200 || len(got) != 0 { t.Fatalf("head: %d body %q", code, got) } if headerValue(headers, "Content-Length") != fmt.Sprint(len(body)) { t.Fatalf("head content-length: %q", headerValue(headers, "Content-Length")) } // Upload check (BUD-06). code, _, _ = httpDo(t, "HEAD", base+"/upload", uploadAuth, nil, map[string]string{"X-SHA-256": hash}) if code != 200 { t.Fatalf("upload check existing: %d", code) } missingHash := strings.Repeat("ef", 32) missingAuth := authEvent(t, owner, 24242, "upload", missingHash, now, now+300) code, _, _ = httpDo(t, "HEAD", base+"/upload", missingAuth, nil, map[string]string{"X-SHA-256": missingHash}) if code != 404 { t.Fatalf("upload check missing: %d", code) } // Listing: public by default, owner-only when authenticated. code, data, _ = httpDo(t, "GET", base+"/list/"+nostr.Hex(mustPub(t, owner)[:]), "", nil, nil) if code != 200 || !strings.Contains(string(data), hash) { t.Fatalf("public list: %d %s", code, data) } listAuth := authEvent(t, other, 24242, "list", "", now, now+300) code, _, _ = httpDo(t, "GET", base+"/list/"+nostr.Hex(mustPub(t, owner)[:]), listAuth, nil, nil) if code != 403 { t.Fatalf("foreign list: %d", code) } ownList := authEvent(t, owner, 24242, "list", "", now, now+300) code, _, _ = httpDo(t, "GET", base+"/list/"+nostr.Hex(mustPub(t, owner)[:]), ownList, nil, nil) if code != 200 { t.Fatalf("owner list: %d", code) } // Deletion is owner-only. code, _, _ = httpDo(t, "DELETE", base+"/"+hash, "", nil, nil) if code != 401 { t.Fatalf("unauthenticated delete: %d", code) } code, _, _ = httpDo(t, "DELETE", base+"/"+hash, authEvent(t, other, 24242, "delete", hash, now, now+300), nil, nil) if code != 403 { t.Fatalf("foreign delete: %d", code) } code, _, _ = httpDo(t, "DELETE", base+"/"+hash, authEvent(t, owner, 24242, "delete", hash, now, now+300), nil, nil) if code != 204 { t.Fatalf("owner delete: %d", code) } code, _, _ = httpDo(t, "GET", base+"/"+hash, "", nil, nil) if code != 404 { t.Fatalf("blob after delete: %d", code) } // Methods and paths. code, _, _ = httpDo(t, "POST", base+"/upload", uploadAuth, body, nil) if code != 405 { t.Fatalf("POST /upload: %d", code) } code, _, _ = httpDo(t, "GET", base+"/"+strings.Repeat("ab", 32), "", nil, nil) if code != 404 { t.Fatalf("missing blob: %d", code) } code, _, _ = httpDo(t, "GET", base+"/notahash", "", nil, nil) if code != 404 { t.Fatalf("non-blossom path: %d", code) } } func mustPub(t *testing.T, sk [32]byte) []byte { t.Helper() pub, err := nostr.PubKeyHex(sk) if err != nil { t.Fatal(err) } var out [32]byte nostr.DecodeRaw(pub, out[:]) return out[:] } func TestBlossomMountAndCORS(t *testing.T) { startBlossomServer(t) now := time.Now().Unix() body := []byte("mounted blossom payload") sum := sha256.Sum256(body) hash := hex.EncodeToString(sum[:]) owner := testKey(1) uploadAuth := authEvent(t, owner, 24242, "upload", hash, now, now+300) code, data, _ := httpDo(t, "OPTIONS", "/blossom/upload", "", nil, map[string]string{ "Origin": "https://smesh.mleku.dev", "Access-Control-Request-Method": "PUT", "Access-Control-Request-Headers": "authorization,content-type", }) if code != 204 { t.Fatalf("preflight: %d %s", code, data) } code, data, _ = httpDo(t, "PUT", "/blossom/upload", uploadAuth, body, map[string]string{"Content-Type": "text/plain"}) if code != 200 { t.Fatalf("mounted upload: %d %s", code, data) } code, got, _ := httpDo(t, "GET", "/blossom/"+hash+".txt", "", nil, nil) if code != 200 || !bytes.Equal(got, body) { t.Fatalf("mounted get with extension: %d %q", code, got) } code, data, _ = httpDo(t, "GET", "/blossom/list/"+nostr.Hex(mustPub(t, owner)[:]), "", nil, nil) if code != 200 || !strings.Contains(string(data), hash) { t.Fatalf("mounted list: %d %s", code, data) } } func TestDescriptorURLHasExtension(t *testing.T) { startBlossomServer(t) now := time.Now().Unix() body := []byte("jpeg-ish payload") sum := sha256.Sum256(body) hash := hex.EncodeToString(sum[:]) owner := testKey(1) uploadAuth := authEvent(t, owner, 24242, "upload", hash, now, now+300) code, data, _ := httpDo(t, "PUT", "/blossom/upload", uploadAuth, body, map[string]string{"Content-Type": "image/jpeg"}) if code != 200 { t.Fatalf("upload: %d %s", code, data) } var desc Descriptor if err := json.Unmarshal(data, &desc); err != nil { t.Fatal(err) } if !strings.HasSuffix(desc.URL, "/"+hash+".jpg") { t.Fatalf("descriptor URL missing extension: %s", desc.URL) } code, got, headers := httpDo(t, "GET", "/blossom/"+hash+".jpg", "", nil, nil) if code != 200 || !bytes.Equal(got, body) { t.Fatalf("extended GET: %d %q", code, got) } if headerValue(headers, "Content-Type") != "image/jpeg" { t.Fatalf("content type: %v", headers) } } func TestExtensionForType(t *testing.T) { cases := map[string]string{ "image/jpeg": ".jpg", "image/jpeg; charset=utf": ".jpg", "image/png": ".png", "video/mp4": ".mp4", "application/octet-stream": "", "": "", } for in, want := range cases { if got := extensionForType(in); got != want { t.Errorf("extensionForType(%q) = %q, want %q", in, got, want) } } }