A community based topic aggregation platform built on atproto
at main 663 lines 19 kB view raw
1package imageproxy 2 3import ( 4 "context" 5 "errors" 6 "net/http" 7 "net/http/httptest" 8 "testing" 9 10 "github.com/go-chi/chi/v5" 11 12 "Coves/internal/atproto/identity" 13 "Coves/internal/core/imageproxy" 14) 15 16// Valid test constants that pass validation 17const ( 18 // validTestDID is a valid did:plc identifier (24 lowercase base32 chars after did:plc:) 19 validTestDID = "did:plc:z72i7hdynmk6r22z27h6tvur" 20 // validTestCID is a valid CIDv1 base32 identifier 21 validTestCID = "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 22) 23 24// mockService implements imageproxy.Service for testing 25type mockService struct { 26 getImageFunc func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) 27} 28 29func (m *mockService) GetImage(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 30 if m.getImageFunc != nil { 31 return m.getImageFunc(ctx, preset, did, cid, pdsURL) 32 } 33 return nil, errors.New("not implemented") 34} 35 36// mockIdentityResolver implements identity.Resolver for testing 37type mockIdentityResolver struct { 38 resolveFunc func(ctx context.Context, identifier string) (*identity.Identity, error) 39 resolveDIDFunc func(ctx context.Context, did string) (*identity.DIDDocument, error) 40} 41 42func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 43 if m.resolveFunc != nil { 44 return m.resolveFunc(ctx, identifier) 45 } 46 return nil, errors.New("not implemented") 47} 48 49func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 50 return "", "", errors.New("not implemented") 51} 52 53func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 54 if m.resolveDIDFunc != nil { 55 return m.resolveDIDFunc(ctx, did) 56 } 57 return nil, errors.New("not implemented") 58} 59 60func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error { 61 return nil 62} 63 64// createTestRequest creates an HTTP request with chi URL params 65func createTestRequest(method, path string, params map[string]string) *http.Request { 66 req := httptest.NewRequest(method, path, nil) 67 rctx := chi.NewRouteContext() 68 for k, v := range params { 69 rctx.URLParams.Add(k, v) 70 } 71 return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) 72} 73 74func TestHandler_HandleImage_Success(t *testing.T) { 75 expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes 76 testPDSURL := "https://pds.example.com" 77 testPreset := "avatar" 78 79 mockSvc := &mockService{ 80 getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 81 if preset != testPreset { 82 t.Errorf("Expected preset %q, got %q", testPreset, preset) 83 } 84 if did != validTestDID { 85 t.Errorf("Expected DID %q, got %q", validTestDID, did) 86 } 87 if cid != validTestCID { 88 t.Errorf("Expected CID %q, got %q", validTestCID, cid) 89 } 90 if pdsURL != testPDSURL { 91 t.Errorf("Expected PDS URL %q, got %q", testPDSURL, pdsURL) 92 } 93 return expectedImage, nil 94 }, 95 } 96 97 mockResolver := &mockIdentityResolver{ 98 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 99 return &identity.DIDDocument{ 100 DID: did, 101 Service: []identity.Service{ 102 { 103 ID: "#atproto_pds", 104 Type: "AtprotoPersonalDataServer", 105 ServiceEndpoint: testPDSURL, 106 }, 107 }, 108 }, nil 109 }, 110 } 111 112 handler := NewHandler(mockSvc, mockResolver) 113 114 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 115 "preset": testPreset, 116 "did": validTestDID, 117 "cid": validTestCID, 118 }) 119 120 w := httptest.NewRecorder() 121 handler.HandleImage(w, req) 122 123 if w.Code != http.StatusOK { 124 t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 125 } 126 127 // Verify Content-Type 128 contentType := w.Header().Get("Content-Type") 129 if contentType != "image/jpeg" { 130 t.Errorf("Expected Content-Type image/jpeg, got %s", contentType) 131 } 132 133 // Verify Cache-Control 134 cacheControl := w.Header().Get("Cache-Control") 135 expectedCacheControl := "public, max-age=31536000, immutable" 136 if cacheControl != expectedCacheControl { 137 t.Errorf("Expected Cache-Control %q, got %q", expectedCacheControl, cacheControl) 138 } 139 140 // Verify ETag format 141 etag := w.Header().Get("ETag") 142 expectedETag := `"avatar-` + validTestCID + `"` 143 if etag != expectedETag { 144 t.Errorf("Expected ETag %q, got %q", expectedETag, etag) 145 } 146 147 // Verify body 148 if w.Body.Len() != len(expectedImage) { 149 t.Errorf("Expected body length %d, got %d", len(expectedImage), w.Body.Len()) 150 } 151} 152 153func TestHandler_HandleImage_ETagMatch_Returns304(t *testing.T) { 154 testPreset := "avatar" 155 156 mockSvc := &mockService{ 157 getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 158 t.Error("Service should not be called when ETag matches") 159 return nil, nil 160 }, 161 } 162 163 mockResolver := &mockIdentityResolver{ 164 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 165 t.Error("Resolver should not be called when ETag matches") 166 return nil, nil 167 }, 168 } 169 170 handler := NewHandler(mockSvc, mockResolver) 171 172 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 173 "preset": testPreset, 174 "did": validTestDID, 175 "cid": validTestCID, 176 }) 177 // Set If-None-Match header with matching ETag 178 req.Header.Set("If-None-Match", `"avatar-`+validTestCID+`"`) 179 180 w := httptest.NewRecorder() 181 handler.HandleImage(w, req) 182 183 if w.Code != http.StatusNotModified { 184 t.Errorf("Expected status 304, got %d. Body: %s", w.Code, w.Body.String()) 185 } 186 187 // Verify no body in 304 response 188 if w.Body.Len() != 0 { 189 t.Errorf("Expected empty body for 304 response, got %d bytes", w.Body.Len()) 190 } 191} 192 193func TestHandler_HandleImage_ETagMismatch_ReturnsImage(t *testing.T) { 194 expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} 195 testPreset := "avatar" 196 testPDSURL := "https://pds.example.com" 197 198 serviceCalled := false 199 mockSvc := &mockService{ 200 getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 201 serviceCalled = true 202 return expectedImage, nil 203 }, 204 } 205 206 mockResolver := &mockIdentityResolver{ 207 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 208 return &identity.DIDDocument{ 209 DID: did, 210 Service: []identity.Service{ 211 { 212 ID: "#atproto_pds", 213 Type: "AtprotoPersonalDataServer", 214 ServiceEndpoint: testPDSURL, 215 }, 216 }, 217 }, nil 218 }, 219 } 220 221 handler := NewHandler(mockSvc, mockResolver) 222 223 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 224 "preset": testPreset, 225 "did": validTestDID, 226 "cid": validTestCID, 227 }) 228 // Set If-None-Match header with different ETag 229 req.Header.Set("If-None-Match", `"other-preset-somecid"`) 230 231 w := httptest.NewRecorder() 232 handler.HandleImage(w, req) 233 234 if w.Code != http.StatusOK { 235 t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 236 } 237 238 if !serviceCalled { 239 t.Error("Service should have been called when ETag doesn't match") 240 } 241} 242 243func TestHandler_HandleImage_InvalidPreset_Returns400(t *testing.T) { 244 mockSvc := &mockService{} 245 mockResolver := &mockIdentityResolver{} 246 247 handler := NewHandler(mockSvc, mockResolver) 248 249 req := createTestRequest(http.MethodGet, "/img/invalid_preset/plain/did:plc:test/somecid", map[string]string{ 250 "preset": "invalid_preset", 251 "did": "did:plc:test", 252 "cid": "somecid", 253 }) 254 255 w := httptest.NewRecorder() 256 handler.HandleImage(w, req) 257 258 if w.Code != http.StatusBadRequest { 259 t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 260 } 261 262 // Verify error response contains error info 263 body := w.Body.String() 264 if body == "" { 265 t.Error("Expected error message in response body") 266 } 267} 268 269func TestHandler_HandleImage_DIDResolutionFailed_Returns502(t *testing.T) { 270 mockSvc := &mockService{} 271 mockResolver := &mockIdentityResolver{ 272 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 273 return nil, errors.New("failed to resolve DID") 274 }, 275 } 276 277 handler := NewHandler(mockSvc, mockResolver) 278 279 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 280 "preset": "avatar", 281 "did": validTestDID, 282 "cid": validTestCID, 283 }) 284 285 w := httptest.NewRecorder() 286 handler.HandleImage(w, req) 287 288 if w.Code != http.StatusBadGateway { 289 t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 290 } 291} 292 293func TestHandler_HandleImage_BlobNotFound_Returns404(t *testing.T) { 294 testPDSURL := "https://pds.example.com" 295 296 mockSvc := &mockService{ 297 getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 298 return nil, imageproxy.ErrPDSNotFound 299 }, 300 } 301 302 mockResolver := &mockIdentityResolver{ 303 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 304 return &identity.DIDDocument{ 305 DID: did, 306 Service: []identity.Service{ 307 { 308 ID: "#atproto_pds", 309 Type: "AtprotoPersonalDataServer", 310 ServiceEndpoint: testPDSURL, 311 }, 312 }, 313 }, nil 314 }, 315 } 316 317 handler := NewHandler(mockSvc, mockResolver) 318 319 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 320 "preset": "avatar", 321 "did": validTestDID, 322 "cid": validTestCID, 323 }) 324 325 w := httptest.NewRecorder() 326 handler.HandleImage(w, req) 327 328 if w.Code != http.StatusNotFound { 329 t.Errorf("Expected status 404, got %d. Body: %s", w.Code, w.Body.String()) 330 } 331} 332 333func TestHandler_HandleImage_Timeout_Returns504(t *testing.T) { 334 testPDSURL := "https://pds.example.com" 335 336 mockSvc := &mockService{ 337 getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 338 return nil, imageproxy.ErrPDSTimeout 339 }, 340 } 341 342 mockResolver := &mockIdentityResolver{ 343 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 344 return &identity.DIDDocument{ 345 DID: did, 346 Service: []identity.Service{ 347 { 348 ID: "#atproto_pds", 349 Type: "AtprotoPersonalDataServer", 350 ServiceEndpoint: testPDSURL, 351 }, 352 }, 353 }, nil 354 }, 355 } 356 357 handler := NewHandler(mockSvc, mockResolver) 358 359 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 360 "preset": "avatar", 361 "did": validTestDID, 362 "cid": validTestCID, 363 }) 364 365 w := httptest.NewRecorder() 366 handler.HandleImage(w, req) 367 368 if w.Code != http.StatusGatewayTimeout { 369 t.Errorf("Expected status 504, got %d. Body: %s", w.Code, w.Body.String()) 370 } 371} 372 373func TestHandler_HandleImage_InternalError_Returns500(t *testing.T) { 374 testPDSURL := "https://pds.example.com" 375 376 mockSvc := &mockService{ 377 getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 378 return nil, errors.New("unexpected internal error") 379 }, 380 } 381 382 mockResolver := &mockIdentityResolver{ 383 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 384 return &identity.DIDDocument{ 385 DID: did, 386 Service: []identity.Service{ 387 { 388 ID: "#atproto_pds", 389 Type: "AtprotoPersonalDataServer", 390 ServiceEndpoint: testPDSURL, 391 }, 392 }, 393 }, nil 394 }, 395 } 396 397 handler := NewHandler(mockSvc, mockResolver) 398 399 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 400 "preset": "avatar", 401 "did": validTestDID, 402 "cid": validTestCID, 403 }) 404 405 w := httptest.NewRecorder() 406 handler.HandleImage(w, req) 407 408 if w.Code != http.StatusInternalServerError { 409 t.Errorf("Expected status 500, got %d. Body: %s", w.Code, w.Body.String()) 410 } 411} 412 413func TestHandler_HandleImage_PDSFetchFailed_Returns502(t *testing.T) { 414 testPDSURL := "https://pds.example.com" 415 416 mockSvc := &mockService{ 417 getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 418 return nil, imageproxy.ErrPDSFetchFailed 419 }, 420 } 421 422 mockResolver := &mockIdentityResolver{ 423 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 424 return &identity.DIDDocument{ 425 DID: did, 426 Service: []identity.Service{ 427 { 428 ID: "#atproto_pds", 429 Type: "AtprotoPersonalDataServer", 430 ServiceEndpoint: testPDSURL, 431 }, 432 }, 433 }, nil 434 }, 435 } 436 437 handler := NewHandler(mockSvc, mockResolver) 438 439 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 440 "preset": "avatar", 441 "did": validTestDID, 442 "cid": validTestCID, 443 }) 444 445 w := httptest.NewRecorder() 446 handler.HandleImage(w, req) 447 448 if w.Code != http.StatusBadGateway { 449 t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 450 } 451} 452 453func TestHandler_HandleImage_MissingParams(t *testing.T) { 454 mockSvc := &mockService{} 455 mockResolver := &mockIdentityResolver{} 456 457 handler := NewHandler(mockSvc, mockResolver) 458 459 tests := []struct { 460 name string 461 params map[string]string 462 }{ 463 { 464 name: "missing preset", 465 params: map[string]string{"did": "did:plc:test", "cid": "somecid"}, 466 }, 467 { 468 name: "missing did", 469 params: map[string]string{"preset": "avatar", "cid": "somecid"}, 470 }, 471 { 472 name: "missing cid", 473 params: map[string]string{"preset": "avatar", "did": "did:plc:test"}, 474 }, 475 { 476 name: "empty preset", 477 params: map[string]string{"preset": "", "did": "did:plc:test", "cid": "somecid"}, 478 }, 479 { 480 name: "empty did", 481 params: map[string]string{"preset": "avatar", "did": "", "cid": "somecid"}, 482 }, 483 { 484 name: "empty cid", 485 params: map[string]string{"preset": "avatar", "did": "did:plc:test", "cid": ""}, 486 }, 487 } 488 489 for _, tc := range tests { 490 t.Run(tc.name, func(t *testing.T) { 491 req := createTestRequest(http.MethodGet, "/img/test/plain/did:plc:test/cid", tc.params) 492 493 w := httptest.NewRecorder() 494 handler.HandleImage(w, req) 495 496 if w.Code != http.StatusBadRequest { 497 t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 498 } 499 }) 500 } 501} 502 503func TestHandler_HandleImage_AllPresets(t *testing.T) { 504 expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} 505 testPDSURL := "https://pds.example.com" 506 507 // Test all valid presets 508 validPresets := []string{"avatar", "avatar_small", "banner", "content_preview", "content_full", "embed_thumbnail"} 509 510 for _, preset := range validPresets { 511 t.Run(preset, func(t *testing.T) { 512 mockSvc := &mockService{ 513 getImageFunc: func(ctx context.Context, p, did, cid, pdsURL string) ([]byte, error) { 514 if p != preset { 515 t.Errorf("Expected preset %q, got %q", preset, p) 516 } 517 return expectedImage, nil 518 }, 519 } 520 521 mockResolver := &mockIdentityResolver{ 522 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 523 return &identity.DIDDocument{ 524 DID: did, 525 Service: []identity.Service{ 526 { 527 ID: "#atproto_pds", 528 Type: "AtprotoPersonalDataServer", 529 ServiceEndpoint: testPDSURL, 530 }, 531 }, 532 }, nil 533 }, 534 } 535 536 handler := NewHandler(mockSvc, mockResolver) 537 538 req := createTestRequest(http.MethodGet, "/img/"+preset+"/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 539 "preset": preset, 540 "did": validTestDID, 541 "cid": validTestCID, 542 }) 543 544 w := httptest.NewRecorder() 545 handler.HandleImage(w, req) 546 547 if w.Code != http.StatusOK { 548 t.Errorf("Expected status 200 for preset %q, got %d. Body: %s", preset, w.Code, w.Body.String()) 549 } 550 551 // Verify ETag matches preset 552 etag := w.Header().Get("ETag") 553 expectedETag := `"` + preset + `-` + validTestCID + `"` 554 if etag != expectedETag { 555 t.Errorf("Expected ETag %q, got %q", expectedETag, etag) 556 } 557 }) 558 } 559} 560 561func TestHandler_HandleImage_NoPDSEndpoint_Returns502(t *testing.T) { 562 mockSvc := &mockService{} 563 mockResolver := &mockIdentityResolver{ 564 resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 565 // Return document without PDS service 566 return &identity.DIDDocument{ 567 DID: did, 568 Service: []identity.Service{}, 569 }, nil 570 }, 571 } 572 573 handler := NewHandler(mockSvc, mockResolver) 574 575 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 576 "preset": "avatar", 577 "did": validTestDID, 578 "cid": validTestCID, 579 }) 580 581 w := httptest.NewRecorder() 582 handler.HandleImage(w, req) 583 584 if w.Code != http.StatusBadGateway { 585 t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 586 } 587} 588 589// TestHandler_HandleImage_InvalidDID tests that invalid DIDs are rejected 590// Note: We use Indigo's syntax.ParseDID for validation consistency with the codebase. 591// Some DIDs that look "wrong" (like did:plc:abc) are actually valid per Indigo's parser. 592func TestHandler_HandleImage_InvalidDID(t *testing.T) { 593 mockSvc := &mockService{} 594 mockResolver := &mockIdentityResolver{} 595 596 handler := NewHandler(mockSvc, mockResolver) 597 598 // These DIDs are invalid per Indigo's syntax.ParseDID (or fail our security checks) 599 // Note: null bytes can't be tested at HTTP layer - Go's HTTP library rejects them first 600 invalidDIDs := []struct { 601 name string 602 did string 603 }{ 604 {"missing method", "did:abc123"}, 605 {"path traversal", "did:plc:../../../etc/passwd"}, 606 {"not a DID", "notadid"}, 607 {"forward slash", "did:plc:abc/def"}, 608 {"backslash", "did:plc:abc\\def"}, 609 {"empty string", ""}, 610 } 611 612 for _, tc := range invalidDIDs { 613 t.Run(tc.name, func(t *testing.T) { 614 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+tc.did+"/"+validTestCID, map[string]string{ 615 "preset": "avatar", 616 "did": tc.did, 617 "cid": validTestCID, 618 }) 619 620 w := httptest.NewRecorder() 621 handler.HandleImage(w, req) 622 623 if w.Code != http.StatusBadRequest { 624 t.Errorf("Expected status 400 for invalid DID %q, got %d. Body: %s", tc.did, w.Code, w.Body.String()) 625 } 626 }) 627 } 628} 629 630// TestHandler_HandleImage_InvalidCID tests that invalid CIDs are rejected 631func TestHandler_HandleImage_InvalidCID(t *testing.T) { 632 mockSvc := &mockService{} 633 mockResolver := &mockIdentityResolver{} 634 635 handler := NewHandler(mockSvc, mockResolver) 636 637 invalidCIDs := []struct { 638 name string 639 cid string 640 }{ 641 {"too short", "bafyabc"}, 642 {"path traversal", "../../../etc/passwd"}, 643 {"contains slash", "bafy/path/to/file"}, 644 {"random string", "this_is_not_a_cid"}, 645 } 646 647 for _, tc := range invalidCIDs { 648 t.Run(tc.name, func(t *testing.T) { 649 req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+tc.cid, map[string]string{ 650 "preset": "avatar", 651 "did": validTestDID, 652 "cid": tc.cid, 653 }) 654 655 w := httptest.NewRecorder() 656 handler.HandleImage(w, req) 657 658 if w.Code != http.StatusBadRequest { 659 t.Errorf("Expected status 400 for invalid CID %q, got %d. Body: %s", tc.cid, w.Code, w.Body.String()) 660 } 661 }) 662 } 663}