tinyrelayfile
tinyrelay/internal/blob/multipart_test.go/

internal/blob/multipart_test.go raw
1package blob
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strconv"
11 "strings"
12 "testing"
13)
14
15func TestMultipartAcceptsOutOfOrderLargeChunks(t *testing.T) {
16 s := testService(t)
17 body := []byte(strings.Repeat("a", 70000) + strings.Repeat("b", 70000))
18 digest := sha256.Sum256(body)
19 sha := hex.EncodeToString(digest[:])
20 patch := func(offset int64, data []byte) *httptest.ResponseRecorder {
21 r := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+sha+".bin", strings.NewReader(string(data)))
22 r.Header.Set("Content-Type", "application/octet-stream")
23 r.Header.Set("Upload-Type", "application/octet-stream")
24 r.Header.Set("Upload-Length", "140000")
25 r.Header.Set("Upload-Offset", formatInt(offset))
26 return record(s.Handler(), r)
27 }
28 if got := patch(70000, body[70000:]); got.Code != http.StatusNoContent {
29 t.Fatalf("second chunk status = %d", got.Code)
30 }
31 if got := patch(0, body[:70000]); got.Code != http.StatusCreated {
32 t.Fatalf("first chunk status = %d: %s", got.Code, got.Body.String())
33 }
34 entry, file, err := s.Get(context.Background(), sha)
35 if err != nil || entry.Size != int64(len(body)) {
36 t.Fatalf("stored multipart blob = %#v, %v", entry, err)
37 }
38 defer file.Close()
39}
40
41func formatInt(value int64) string { return strconv.FormatInt(value, 10) }
42
43func TestMultipartRejectsMalformedRangesAndLength(t *testing.T) {
44 s := testService(t)
45 digest := sha256.Sum256([]byte("abc"))
46 sha := hex.EncodeToString(digest[:])
47 tests := []struct {
48 name string
49 contentLength int64
50 uploadLength string
51 offset string
52 want int
53 }{
54 {"missing content length", -1, "3", "0", http.StatusLengthRequired},
55 {"offset outside upload", 1, "3", "3", http.StatusRequestedRangeNotSatisfiable},
56 {"chunk crosses upload", 2, "3", "2", http.StatusRequestedRangeNotSatisfiable},
57 }
58 for _, tc := range tests {
59 t.Run(tc.name, func(t *testing.T) {
60 r := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+sha, strings.NewReader("ab"))
61 r.ContentLength = tc.contentLength
62 r.Header.Set("Content-Type", "application/octet-stream")
63 r.Header.Set("Upload-Type", "text/plain")
64 r.Header.Set("Upload-Length", tc.uploadLength)
65 r.Header.Set("Upload-Offset", tc.offset)
66 if got := record(s.Handler(), r).Code; got != tc.want {
67 t.Fatalf("status = %d, want %d", got, tc.want)
68 }
69 })
70 }
71}
72
73func TestMultipartMismatchDoesNotPublishBlob(t *testing.T) {
74 s := testService(t)
75 want := strings.Repeat("0", 64)
76 r := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+want, strings.NewReader("wrong"))
77 r.Header.Set("Content-Type", "application/octet-stream")
78 r.Header.Set("Upload-Type", "text/plain")
79 r.Header.Set("Upload-Length", "5")
80 r.Header.Set("Upload-Offset", "0")
81 if got := record(s.Handler(), r).Code; got != http.StatusConflict {
82 t.Fatalf("status = %d, want 409", got)
83 }
84 if _, _, err := s.Get(context.Background(), want); err == nil {
85 t.Fatal("mismatched multipart upload was published")
86 }
87}
88
89func TestMultipartOverlappingChunksReconstructExactBytes(t *testing.T) {
90 s := testService(t)
91 body := []byte("abcdefghij")
92 digest := sha256.Sum256(body)
93 sha := hex.EncodeToString(digest[:])
94 request := func(offset int, chunk string) int {
95 r := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+sha, strings.NewReader(chunk))
96 r.Header.Set("Content-Type", "application/octet-stream")
97 r.Header.Set("Upload-Type", "text/plain")
98 r.Header.Set("Upload-Length", "10")
99 r.Header.Set("Upload-Offset", formatInt(int64(offset)))
100 return record(s.Handler(), r).Code
101 }
102 if got := request(0, "abcdef"); got != http.StatusNoContent {
103 t.Fatalf("first status = %d", got)
104 }
105 if got := request(4, "efghij"); got != http.StatusCreated {
106 t.Fatalf("overlap status = %d", got)
107 }
108 _, file, err := s.Get(context.Background(), sha)
109 if err != nil {
110 t.Fatal(err)
111 }
112 defer file.Close()
113 got, err := io.ReadAll(file)
114 if err != nil || string(got) != string(body) {
115 t.Fatalf("body = %q, %v", got, err)
116 }
117}
118
119func TestMultipartResumesAfterServiceRestart(t *testing.T) {
120 original := testService(t)
121 body := "survives restart"
122 digest := sha256.Sum256([]byte(body))
123 hash := hex.EncodeToString(digest[:])
124 patch := func(service *Service, offset int, chunk string) *httptest.ResponseRecorder {
125 request := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+hash, strings.NewReader(chunk))
126 request.Header.Set("Content-Type", "application/octet-stream")
127 request.Header.Set("Upload-Type", "text/plain")
128 request.Header.Set("Upload-Length", strconv.Itoa(len(body)))
129 request.Header.Set("Upload-Offset", strconv.Itoa(offset))
130 return record(service.Handler(), request)
131 }
132 if result := patch(original, 0, body[:5]); result.Code != 204 {
133 t.Fatalf("partial: %d %s", result.Code, result.Body.String())
134 }
135 reopened, err := New(context.Background(), original.config)
136 if err != nil {
137 t.Fatal(err)
138 }
139 if result := patch(reopened, 5, body[5:]); result.Code != 201 {
140 t.Fatalf("resumed: %d %s", result.Code, result.Body.String())
141 }
142}
143
144func TestMultipartPreservesClaimMetadataAcrossChunks(t *testing.T) {
145 s := testService(t)
146 body := "resumable metadata"
147 digest := sha256.Sum256([]byte(body))
148 hash := hex.EncodeToString(digest[:])
149 patch := func(offset int, chunk string, query string) int {
150 r := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+hash+query, strings.NewReader(chunk))
151 r.Header.Set("Content-Type", "application/octet-stream")
152 r.Header.Set("Upload-Type", "text/plain")
153 r.Header.Set("Upload-Length", strconv.Itoa(len(body)))
154 r.Header.Set("Upload-Offset", strconv.Itoa(offset))
155 return record(s.Handler(), r).Code
156 }
157 if got := patch(0, body[:9], "?filename=message.txt&path=notes%2Fmessage.txt"); got != http.StatusNoContent {
158 t.Fatalf("partial status = %d", got)
159 }
160 if got := patch(9, body[9:], ""); got != http.StatusCreated {
161 t.Fatalf("complete status = %d", got)
162 }
163 entries, err := s.ListClaimMetadata(context.Background(), "uploader")
164 if err != nil || len(entries) != 1 || entries[0].Name != "message.txt" || entries[0].Path != "notes/message.txt" {
165 t.Fatalf("metadata = %+v, %v", entries, err)
166 }
167}
168
169func TestMultipartAcceptsEmptyFile(t *testing.T) {
170 service := testService(t)
171 digest := sha256.Sum256(nil)
172 request := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+hex.EncodeToString(digest[:]), http.NoBody)
173 request.Header.Set("Content-Type", "application/octet-stream")
174 request.Header.Set("Upload-Type", "application/octet-stream")
175 request.Header.Set("Upload-Length", "0")
176 request.Header.Set("Upload-Offset", "0")
177 if result := record(service.Handler(), request); result.Code != 201 {
178 t.Fatalf("empty: %d %s", result.Code, result.Body.String())
179 }
180}
181
182func TestMultipartExistingBlobAddsUploaderClaim(t *testing.T) {
183 service := testService(t)
184 const content = "deduplicated"
185 entry, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(content), Uploader: "first"})
186 if err != nil {
187 t.Fatal(err)
188 }
189 request := multipartReviewRequest(entry.SHA256, int64(len(content)), 0, content[:2])
190 if result := record(service.Handler(), request); result.Code != 200 {
191 t.Fatalf("duplicate: %d %s", result.Code, result.Body.String())
192 }
193 if !service.hasClaim(context.Background(), entry.SHA256, "uploader") {
194 t.Fatal("existing blob did not grant the uploader a claim")
195 }
196}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs