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