1package daemon23import (4 "crypto/sha256"5 "encoding/base64"6 "encoding/hex"7 "encoding/json"8 "net/http"9 "net/http/httptest"10 "strconv"11 "strings"12 "testing"13 "time"1415 "github.com/FelineStateMachine/tinyrelay/internal/blob"16 "github.com/FelineStateMachine/tinyrelay/internal/event"17)1819func multipartAuthorization(t *testing.T, hash string) string {20 t.Helper()21 token := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{22 {"t", "upload"}, {"x", hash}, {"expiration", strconv.FormatInt(time.Now().Unix()+60, 10)},23 }, Content: "Authorize multipart upload"}24 if err := event.Sign(&token, strings.Repeat("0", 63)+"1"); err != nil {25 t.Fatal(err)26 }27 raw, err := json.Marshal(token)28 if err != nil {29 t.Fatal(err)30 }31 return "Nostr " + base64.RawURLEncoding.EncodeToString(raw)32}3334func TestMultipartAuthorizationBindsFinalHashBeforeReading(t *testing.T) {35 _, tenant := testTenant(t)36 finalHash := strings.Repeat("a", 64)37 r := httptest.NewRequest(http.MethodPatch, "http://relay.test/"+finalHash+".txt", strings.NewReader("chunk"))38 r.Header.Set("Authorization", multipartAuthorization(t, strings.Repeat("b", 64)))39 if _, err := tenant.authorizeBlob(r, blob.ActionUpload); err == nil {40 t.Fatal("multipart authorization accepted another final hash")41 }42 r.Header.Set("Authorization", multipartAuthorization(t, finalHash))43 if _, err := tenant.authorizeBlob(r, blob.ActionUpload); err != nil {44 t.Fatal(err)45 }46 chunkHash := sha256.Sum256([]byte("chunk"))47 if err := tenant.validateBlobUpload(r, hex.EncodeToString(chunkHash[:])); err != nil {48 t.Fatalf("Blossom proof incorrectly required a chunk hash: %v", err)49 }50}5152func TestMultipartNIP98BindsEachChunkBody(t *testing.T) {53 _, tenant := testTenant(t)54 r := httptest.NewRequest(http.MethodPatch, "http://relay.test/"+strings.Repeat("c", 64), strings.NewReader("chunk"))55 signRequest(t, r, "chunk")56 if _, err := tenant.authorizeBlob(r, blob.ActionUpload); err != nil {57 t.Fatal(err)58 }59 chunkHash := sha256.Sum256([]byte("chunk"))60 if err := tenant.validateBlobUpload(r, hex.EncodeToString(chunkHash[:])); err != nil {61 t.Fatal(err)62 }63 if err := tenant.validateBlobUpload(r, strings.Repeat("c", 64)); err == nil {64 t.Fatal("NIP-98 proof accepted final hash in place of chunk payload")65 }66}6768func TestMultipartPreflightAdvertisesMountedFileDoor(t *testing.T) {69 _, tenant := testTenant(t)70 r := httptest.NewRequest(http.MethodOptions, "http://relay.test/"+strings.Repeat("a", 64), nil)71 w := httptest.NewRecorder()72 tenant.ServeHTTP(w, r)73 if w.Code != http.StatusNoContent || !strings.Contains(w.Header().Get("Allow"), "PATCH") {74 t.Fatalf("multipart preflight: %d, Allow=%q", w.Code, w.Header().Get("Allow"))75 }76 for _, header := range []string{"upload-type", "upload-length", "upload-offset"} {77 if !strings.Contains(strings.ToLower(w.Header().Get("Access-Control-Allow-Headers")), header) {78 t.Fatalf("multipart CORS does not allow %s", header)79 }80 }81 if !strings.Contains(w.Header().Get("Access-Control-Expose-Headers"), "Allow") {82 t.Fatal("browser cannot read PATCH support")83 }84 for _, capability := range tenant.Capabilities(nil) {85 if capability.ID == "BUD-14" && capability.Status == "enabled" {86 return87 }88 }89 t.Fatal("BUD-14 capability is missing")90}9192func TestMultipartHTTPVerifiesEachNIP98ChunkBeforeAcceptingIt(t *testing.T) {93 _, tenant := testTenant(t)94 const body = "first and second"95 digest := sha256.Sum256([]byte(body))96 hash := hex.EncodeToString(digest[:])97 patch := func(offset int, chunk, signed string) *httptest.ResponseRecorder {98 r := httptest.NewRequest(http.MethodPatch, "http://relay.test/"+hash, strings.NewReader(chunk))99 r.Header.Set("Content-Type", "application/octet-stream")100 r.Header.Set("Upload-Type", "text/plain")101 r.Header.Set("Upload-Length", strconv.Itoa(len(body)))102 r.Header.Set("Upload-Offset", strconv.Itoa(offset))103 signRequest(t, r, signed)104 w := httptest.NewRecorder()105 tenant.ServeHTTP(w, r)106 return w107 }108 if w := patch(0, body[:6], "altered"); w.Code != http.StatusUnauthorized {109 t.Fatalf("invalid chunk proof = %d: %s", w.Code, w.Body.String())110 }111 if w := patch(6, body[6:], body[6:]); w.Code != http.StatusNoContent {112 t.Fatalf("out-of-order chunk = %d: %s", w.Code, w.Body.String())113 }114 if w := patch(0, body[:6], body[:6]); w.Code != http.StatusCreated {115 t.Fatalf("complete upload = %d: %s", w.Code, w.Body.String())116 }117 if w := patch(0, body[:6], "still altered"); w.Code != http.StatusUnauthorized {118 t.Fatalf("existing blob bypassed payload validation = %d: %s", w.Code, w.Body.String())119 }120 get := httptest.NewRecorder()121 tenant.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "http://relay.test/"+hash, nil))122 if get.Code != http.StatusOK || get.Body.String() != body {123 t.Fatalf("retrieved upload = %d: %s", get.Code, get.Body.String())124 }125}