| 1 | package blob |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 20 | ) |
| 21 | |
| 22 | func TestUploadDeduplicatesAndServesByHash(t *testing.T) { |
| 23 | service := testService(t) |
| 24 | body := []byte("hello blob" ) |
| 25 | hash := sha256.Sum256(body) |
| 26 | digest := hex.EncodeToString(hash[:]) |
| 27 | req := httptest.NewRequest(http.MethodPut, "https://relay.test/upload" , strings.NewReader(string(body))) |
| 28 | req.Header.Set("Content-Type" , "text/plain" ) |
| 29 | first := service.Handler().ServeHTTP |
| 30 | response := record(first, req) |
| 31 | if response.Code != http.StatusOK { |
| 32 | t.Fatalf("upload status = %d, want 200 (%s)" , response.Code, response.Body.String()) |
| 33 | } |
| 34 | if !strings.Contains(response.Body.String(), digest) { |
| 35 | t.Fatalf("upload descriptor lacks hash: %s" , response.Body.String()) |
| 36 | } |
| 37 | req = httptest.NewRequest(http.MethodPut, "https://relay.test/upload" , strings.NewReader(string(body))) |
| 38 | req.Header.Set("Content-Type" , "text/plain" ) |
| 39 | response = record(first, req) |
| 40 | if response.Code != http.StatusOK { |
| 41 | t.Fatalf("duplicate upload status = %d, want 200" , response.Code) |
| 42 | } |
| 43 | response = record(first, httptest.NewRequest(http.MethodGet, "https://relay.test/" +digest+".txt" , nil)) |
| 44 | if response.Code != http.StatusOK || response.Body.String() != string(body) { |
| 45 | t.Fatalf("download = %d %q" , response.Code, response.Body.String()) |
| 46 | } |
| 47 | if response.Header().Get("ETag" ) != `"` +digest+`"` { |
| 48 | t.Fatalf("etag = %q" , response.Header().Get("ETag" )) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestUploadEnforcesFileAndUploaderLimits(t *testing.T) { |
| 53 | service := testService(t) |
| 54 | service.config.Limits = func() Limits { return Limits{MaxFileBytes: 4, UserStorageBytes: 6} } |
| 55 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("12345" ), Type: "text/plain" , Uploader: testUploader}); !errors.Is(err, ErrFileTooLarge) { |
| 56 | t.Fatalf("oversized upload error = %v" , err) |
| 57 | } |
| 58 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("1234" ), Type: "text/plain" , Uploader: testUploader}); err != nil { |
| 59 | t.Fatalf("first limited upload: %v" , err) |
| 60 | } |
| 61 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("5678" ), Type: "text/plain" , Uploader: testUploader}); !errors.Is(err, ErrQuotaExceeded) { |
| 62 | t.Fatalf("quota upload error = %v" , err) |
| 63 | } |
| 64 | // A deduplicated claim does not consume additional physical storage. |
| 65 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("1234" ), Type: "text/plain" , Uploader: strings.Repeat("b" , 64)}); err != nil { |
| 66 | t.Fatalf("deduplicated upload: %v" , err) |
| 67 | } |
| 68 | second := strings.Repeat("b" , 64) |
| 69 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("abc" ), Type: "text/plain" , Uploader: strings.Repeat("c" , 64)}); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("56" ), Type: "text/plain" , Uploader: second}); err != nil { |
| 73 | t.Fatalf("second uploader upload: %v" , err) |
| 74 | } |
| 75 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("xyz" ), Type: "text/plain" , Uploader: second}); !errors.Is(err, ErrQuotaExceeded) { |
| 76 | t.Fatalf("second uploader remaining quota error = %v" , err) |
| 77 | } |
| 78 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("abc" ), Type: "text/plain" , Uploader: second}); !errors.Is(err, ErrQuotaExceeded) { |
| 79 | t.Fatalf("existing blob claim bypassed remaining quota: %v" , err) |
| 80 | } |
| 81 | digest := sha256.Sum256([]byte("56" )) |
| 82 | sha := hex.EncodeToString(digest[:]) |
| 83 | if err := service.DeleteForUploader(context.Background(), sha, second); err != nil { |
| 84 | t.Fatalf("remove second uploader claim: %v" , err) |
| 85 | } |
| 86 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("ab" ), Type: "text/plain" , Uploader: second}); err != nil { |
| 87 | t.Fatalf("quota after claim deletion: %v" , err) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestConcurrentUploadsRespectUploaderQuota(t *testing.T) { |
| 92 | service := testService(t) |
| 93 | service.config.Limits = func() Limits { return Limits{UserStorageBytes: 4} } |
| 94 | var wg sync.WaitGroup |
| 95 | errs := make(chan error, 2) |
| 96 | for _, body := range []string{"aaaa" , "bbbb" } { |
| 97 | wg.Add(1) |
| 98 | go func(body string) { |
| 99 | defer wg.Done() |
| 100 | _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(body), Type: "text/plain" , Uploader: testUploader}) |
| 101 | errs <- err |
| 102 | }(body) |
| 103 | } |
| 104 | wg.Wait() |
| 105 | close(errs) |
| 106 | var success, quota int |
| 107 | for err := range errs { |
| 108 | if err == nil { |
| 109 | success++ |
| 110 | } else if errors.Is(err, ErrQuotaExceeded) { |
| 111 | quota++ |
| 112 | } |
| 113 | } |
| 114 | if success != 1 || quota != 1 { |
| 115 | t.Fatalf("concurrent quota results success=%d quota=%d" , success, quota) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | func TestQuotaOnlyLimitReportsQuotaBeforeHashMismatch(t *testing.T) { |
| 120 | service := testService(t) |
| 121 | service.config.Limits = func() Limits { return Limits{UserStorageBytes: 4} } |
| 122 | _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("12345" ), Type: "text/plain" , Uploader: testUploader, Hash: strings.Repeat("a" , 64)}) |
| 123 | if !errors.Is(err, ErrQuotaExceeded) { |
| 124 | t.Fatalf("quota-only oversized upload error = %v" , err) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestSlowUploadDoesNotHoldQuotaLockWhileReading(t *testing.T) { |
| 129 | service := testService(t) |
| 130 | reader, writer := io.Pipe() |
| 131 | done := make(chan error, 1) |
| 132 | go func() { |
| 133 | _, err := service.Put(context.Background(), PutOptions{Reader: reader, Type: "text/plain" , Uploader: testUploader}) |
| 134 | done <- err |
| 135 | }() |
| 136 | select { |
| 137 | case <-done: |
| 138 | t.Fatal("stalled upload returned before receiving data" ) |
| 139 | case <-time.After(25 * time.Millisecond): |
| 140 | } |
| 141 | second := strings.Repeat("b" , 64) |
| 142 | finished := make(chan error, 1) |
| 143 | go func() { |
| 144 | _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("fast" ), Type: "text/plain" , Uploader: second}) |
| 145 | finished <- err |
| 146 | }() |
| 147 | select { |
| 148 | case err := <-finished: |
| 149 | if err != nil { |
| 150 | t.Fatalf("second upload: %v" , err) |
| 151 | } |
| 152 | case <-time.After(time.Second): |
| 153 | t.Fatal("second upload blocked behind stalled stream" ) |
| 154 | } |
| 155 | _, _ = writer.Write([]byte("slow" )) |
| 156 | _ = writer.Close() |
| 157 | if err := <-done; err != nil { |
| 158 | t.Fatalf("stalled upload completion: %v" , err) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | func TestPrivateBlobRequiresReadAuthorization(t *testing.T) { |
| 163 | service := testService(t) |
| 164 | service.config.CanRead = func(context.Context, string, []string) bool { return false } |
| 165 | service.config.Authorize = func(*http.Request, Action) (string, error) { return "" , os.ErrPermission } |
| 166 | response := record(service.Handler().ServeHTTP, httptest.NewRequest(http.MethodGet, "https://relay.test/" +strings.Repeat("a" , 64), nil)) |
| 167 | if response.Code != http.StatusUnauthorized { |
| 168 | t.Fatalf("private blob status = %d, want 401" , response.Code) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestMirrorURLRejectsPrivateAndNonHTTPS(t *testing.T) { |
| 173 | for _, raw := range []string{"http://example.com/a" , "https://127.0.0.1/a" , "https://[::1]/a" , "https://localhost/a" } { |
| 174 | if err := validateMirrorURL(raw, func(context.Context, string) ([]net.IP, error) { return nil, nil }); err == nil { |
| 175 | t.Errorf("validateMirrorURL(%q) succeeded" , raw) |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestMirrorBlossomURIFallsBackAndVerifiesHash(t *testing.T) { |
| 181 | service := testService(t) |
| 182 | body := []byte("fallback body" ) |
| 183 | hash := sha256.Sum256(body) |
| 184 | digest := hex.EncodeToString(hash[:]) |
| 185 | first := "https://first.example/" + digest |
| 186 | author := strings.Repeat("c" , 64) |
| 187 | service.config.ResolveIP = func(context.Context, string) ([]net.IP, error) { return []net.IP{net.ParseIP("8.8.8.8" )}, nil } |
| 188 | service.resolve = service.config.ResolveIP |
| 189 | service.config.ResolveServers = func(_ context.Context, pubkey string) ([]string, error) { |
| 190 | if pubkey != author { |
| 191 | t.Fatalf("fallback resolved uploader %q, want URI author" , pubkey) |
| 192 | } |
| 193 | return []string{"https://second.example" }, nil |
| 194 | } |
| 195 | service.config.HTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { |
| 196 | if req.URL.String() == first { |
| 197 | return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody, Header: make(http.Header), Request: req}, nil |
| 198 | } |
| 199 | return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(body))), Header: http.Header{"Content-Type" : []string{"text/plain" }}, Request: req}, nil |
| 200 | })} |
| 201 | uri := "blossom:" + digest + "?xs=first.example&as=" + author |
| 202 | request := httptest.NewRequest(http.MethodPut, "https://relay.test/mirror" , strings.NewReader(`{"url":"` +uri+`"}` )) |
| 203 | response := record(service.Handler().ServeHTTP, request) |
| 204 | if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), digest) { |
| 205 | t.Fatalf("fallback mirror = %d %s" , response.Code, response.Body.String()) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func TestMirrorBlossomURIRejectsWrongHash(t *testing.T) { |
| 210 | service := testService(t) |
| 211 | wanted := strings.Repeat("a" , 64) |
| 212 | service.config.ResolveIP = func(context.Context, string) ([]net.IP, error) { return []net.IP{net.ParseIP("8.8.8.8" )}, nil } |
| 213 | service.resolve = service.config.ResolveIP |
| 214 | service.config.ResolveServers = func(context.Context, string) ([]string, error) { return []string{"https://origin.example" }, nil } |
| 215 | service.config.HTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { |
| 216 | return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("wrong" )), Header: make(http.Header), Request: req}, nil |
| 217 | })} |
| 218 | request := httptest.NewRequest(http.MethodPut, "https://relay.test/mirror" , strings.NewReader(`{"url":"blossom:` +wanted+`"}` )) |
| 219 | response := record(service.Handler().ServeHTTP, request) |
| 220 | if response.Code != http.StatusConflict { |
| 221 | t.Fatalf("wrong hash status = %d, want 409" , response.Code) |
| 222 | } |
| 223 | if _, _, err := service.Get(context.Background(), wanted); err == nil { |
| 224 | t.Fatal("wrong hash was stored" ) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | type roundTripFunc func(*http.Request) (*http.Response, error) |
| 229 | |
| 230 | func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| 231 | |
| 232 | func TestDeleteTombstonePreventsOrphanResurrection(t *testing.T) { |
| 233 | service := testService(t) |
| 234 | body := []byte("delete me" ) |
| 235 | digest := sha256.Sum256(body) |
| 236 | sha := hex.EncodeToString(digest[:]) |
| 237 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain" , Uploader: testUploader}); err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | if err := service.Delete(context.Background(), sha); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if err := os.WriteFile(filepath.Join(service.root, sha), body, 0o600); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | if _, err := New(context.Background(), service.config); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | if _, _, err := service.Get(context.Background(), sha); err == nil { |
| 250 | t.Fatal("deleted orphan was resurrected" ) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | func TestBlobClaimsPreserveDeduplicatedOwnershipAndCursor(t *testing.T) { |
| 255 | service := testService(t) |
| 256 | body := "shared blob" |
| 257 | first, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(body), Type: "text/plain" , Uploader: testUploader}) |
| 258 | if err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | secondUploader := strings.Repeat("b" , 64) |
| 262 | second, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(body), Type: "text/plain" , Uploader: secondUploader}) |
| 263 | if err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | if first.SHA256 != second.SHA256 { |
| 267 | t.Fatalf("deduplicated hashes differ: %q and %q" , first.SHA256, second.SHA256) |
| 268 | } |
| 269 | if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader("another blob" ), Type: "text/plain" , Uploader: testUploader}); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | page, next, err := service.ListPage(context.Background(), testUploader, 1, "" ) |
| 273 | if err != nil || len(page) != 1 || next == "" { |
| 274 | t.Fatalf("first page = %#v next=%q err=%v" , page, next, err) |
| 275 | } |
| 276 | page, next, err = service.ListPage(context.Background(), secondUploader, 1, "" ) |
| 277 | if err != nil || len(page) != 1 || next != "" { |
| 278 | t.Fatalf("second owner page = %#v next=%q err=%v" , page, next, err) |
| 279 | } |
| 280 | if err := service.DeleteForUploader(context.Background(), first.SHA256, testUploader); err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | if _, _, err := service.Get(context.Background(), second.SHA256); err != nil { |
| 284 | t.Fatalf("shared content removed with one claim: %v" , err) |
| 285 | } |
| 286 | if err := service.DeleteForUploader(context.Background(), second.SHA256, secondUploader); err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | if _, _, err := service.Get(context.Background(), second.SHA256); err == nil { |
| 290 | t.Fatal("final claim deletion retained blob" ) |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | // TestAgentUploadsExpireUnlessAPersonClaimsThem covers the ttl an agent |
| 295 | // grant puts on uploads: claims carry the expiry, the sweep removes blobs |
| 296 | // whose every claim lapsed and keeps those a person holds. |
| 297 | func TestAgentUploadsExpireUnlessAPersonClaimsThem(t *testing.T) { |
| 298 | service := testService(t) |
| 299 | agent := strings.Repeat("a" , 64) |
| 300 | person := strings.Repeat("b" , 64) |
| 301 | now := time.Now().Unix() |
| 302 | service.config.UploadTerms = func(_ context.Context, uploader string) (UploadTerms, error) { |
| 303 | if uploader == agent { |
| 304 | return UploadTerms{ExpiresAt: now + 10}, nil |
| 305 | } |
| 306 | return UploadTerms{}, nil |
| 307 | } |
| 308 | ctx := context.Background() |
| 309 | shared, err := service.Put(ctx, PutOptions{Reader: strings.NewReader("shared site file" ), Type: "text/html" , Uploader: agent}) |
| 310 | if err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | alone, err := service.Put(ctx, PutOptions{Reader: strings.NewReader("agent only file" ), Type: "text/html" , Uploader: agent}) |
| 314 | if err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | var expires int64 |
| 318 | if err := service.store.DB().QueryRow("SELECT expires FROM blob_claims WHERE sha256=? AND uploader=?" , alone.SHA256, agent).Scan(&expires); err != nil || expires != now+10 { |
| 319 | t.Fatalf("agent claim expires=%d err=%v, want %d" , expires, err, now+10) |
| 320 | } |
| 321 | if _, err := service.Put(ctx, PutOptions{Reader: strings.NewReader("shared site file" ), Type: "text/html" , Uploader: person}); err != nil { |
| 322 | t.Fatal(err) |
| 323 | } |
| 324 | if err := service.store.DB().QueryRow("SELECT expires FROM blob_claims WHERE sha256=? AND uploader=?" , shared.SHA256, person).Scan(&expires); err != nil || expires != 0 { |
| 325 | t.Fatalf("person claim expires=%d err=%v, want 0" , expires, err) |
| 326 | } |
| 327 | if removed, err := service.SweepExpired(ctx, now+9); err != nil || removed != 0 { |
| 328 | t.Fatalf("early sweep removed %d err=%v" , removed, err) |
| 329 | } |
| 330 | if removed, err := service.SweepExpired(ctx, now+10); err != nil || removed != 1 { |
| 331 | t.Fatalf("sweep removed %d err=%v, want 1" , removed, err) |
| 332 | } |
| 333 | if _, _, err := service.Get(ctx, alone.SHA256); err == nil { |
| 334 | t.Fatal("expired unclaimed blob still served" ) |
| 335 | } |
| 336 | if _, err := os.Stat(filepath.Join(service.root, alone.SHA256)); !errors.Is(err, os.ErrNotExist) { |
| 337 | t.Fatalf("expired blob file remains: %v" , err) |
| 338 | } |
| 339 | if _, _, err := service.Get(ctx, shared.SHA256); err != nil { |
| 340 | t.Fatalf("blob a person claimed was removed: %v" , err) |
| 341 | } |
| 342 | mine, err := service.ListMetadata(ctx, agent) |
| 343 | if err != nil || len(mine) != 0 { |
| 344 | t.Fatalf("agent still lists %d blobs after expiry: %v" , len(mine), err) |
| 345 | } |
| 346 | theirs, err := service.ListMetadata(ctx, person) |
| 347 | if err != nil || len(theirs) != 1 { |
| 348 | t.Fatalf("person lists %d blobs: %v" , len(theirs), err) |
| 349 | } |
| 350 | // A restart keeps the expiry column and the earlier rows. |
| 351 | if _, err := New(ctx, service.config); err != nil { |
| 352 | t.Fatal(err) |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | func TestEncryptedOnlyTermsRefusePlainUploads(t *testing.T) { |
| 357 | service := testService(t) |
| 358 | agent := strings.Repeat("a" , 64) |
| 359 | service.config.UploadTerms = func(_ context.Context, uploader string) (UploadTerms, error) { |
| 360 | return UploadTerms{Encrypted: uploader == agent}, nil |
| 361 | } |
| 362 | ctx := context.Background() |
| 363 | png := "\x89PNG\r\n\x1a\n" + strings.Repeat("\x00" , 64) |
| 364 | for name, options := range map[string]PutOptions{ |
| 365 | "declared page" : {Reader: strings.NewReader("<html>plain</html>" ), Type: "text/html" , Uploader: agent}, |
| 366 | "sniffed image" : {Reader: strings.NewReader(png), Type: "application/octet-stream" , Uploader: agent}, |
| 367 | "declared manifest" : {Reader: strings.NewReader("{}" ), Type: "application/json" , Uploader: agent}, |
| 368 | } { |
| 369 | if _, err := service.Put(ctx, options); !errors.Is(err, ErrNotEncrypted) { |
| 370 | t.Fatalf("%s: err=%v, want ErrNotEncrypted" , name, err) |
| 371 | } |
| 372 | } |
| 373 | if entries, _ := os.ReadDir(service.root); len(entries) != 0 { |
| 374 | t.Fatalf("refused uploads left %d files" , len(entries)) |
| 375 | } |
| 376 | cipher := "\x8f\x1a\x9c\x03" + strings.Repeat("\xe2\x71\x05\x9b\x4c" , 20) |
| 377 | if _, err := service.Put(ctx, PutOptions{Reader: strings.NewReader(cipher), Type: "application/octet-stream" , Uploader: agent}); err != nil { |
| 378 | t.Fatalf("ciphertext refused: %v" , err) |
| 379 | } |
| 380 | if _, err := service.Put(ctx, PutOptions{Reader: strings.NewReader("\x82\xa1t\x01" ), Type: "application/vnd.blossom.directory+msgpack" , Uploader: agent}); err != nil { |
| 381 | t.Fatalf("encrypted manifest refused: %v" , err) |
| 382 | } |
| 383 | // Claiming a plain blob someone else stored is refused too. |
| 384 | if _, err := service.Put(ctx, PutOptions{Reader: strings.NewReader("<html>theirs</html>" ), Type: "text/html" , Uploader: strings.Repeat("b" , 64)}); err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | if _, err := service.Put(ctx, PutOptions{Reader: strings.NewReader("<html>theirs</html>" ), Type: "text/html" , Uploader: agent}); !errors.Is(err, ErrNotEncrypted) { |
| 388 | t.Fatalf("claim of a plain blob: err=%v" , err) |
| 389 | } |
| 390 | // The door answers 403 with the reason. |
| 391 | service.config.Authorize = func(*http.Request, Action) (string, error) { return agent, nil } |
| 392 | req := httptest.NewRequest(http.MethodPut, "https://relay.test/upload" , strings.NewReader("<html>plain</html>" )) |
| 393 | req.Header.Set("Content-Type" , "text/html" ) |
| 394 | response := record(service.Handler().ServeHTTP, req) |
| 395 | if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "requires encrypted uploads" ) { |
| 396 | t.Fatalf("plain upload = %d %s" , response.Code, response.Body.String()) |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | const testUploader = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
| 401 | |
| 402 | func testService(t *testing.T) *Service { |
| 403 | t.Helper() |
| 404 | root := t.TempDir() |
| 405 | store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db" )) |
| 406 | if err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | t.Cleanup(func() { _ = store.Close() }) |
| 410 | service, err := New(context.Background(), Config{Root: root, Store: store, Authorize: func(*http.Request, Action) (string, error) { return "uploader" , nil }}) |
| 411 | if err != nil { |
| 412 | t.Fatal(err) |
| 413 | } |
| 414 | return service |
| 415 | } |
| 416 | |
| 417 | func record(handler http.HandlerFunc, req *http.Request) *httptest.ResponseRecorder { |
| 418 | response := httptest.NewRecorder() |
| 419 | handler(response, req) |
| 420 | return response |
| 421 | } |