| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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" |
| 14 | |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 16 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 17 | ) |
| 18 | |
| 19 | func 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 | } |
| 33 | |
| 34 | func 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 | } |
| 51 | |
| 52 | func 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 | } |
| 67 | |
| 68 | func 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 | return |
| 87 | } |
| 88 | } |
| 89 | t.Fatal("BUD-14 capability is missing" ) |
| 90 | } |
| 91 | |
| 92 | func 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 w |
| 107 | } |
| 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 | } |