A community based topic aggregation platform built on atproto
at main 943 lines 33 kB view raw
1package integration 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "image" 9 "image/color" 10 "image/png" 11 "net/http" 12 "net/http/httptest" 13 "os" 14 "strings" 15 "testing" 16 "time" 17 18 "github.com/disintegration/imaging" 19 "github.com/go-chi/chi/v5" 20 "github.com/stretchr/testify/assert" 21 "github.com/stretchr/testify/require" 22 23 "Coves/internal/api/handlers/imageproxy" 24 "Coves/internal/api/routes" 25 "Coves/internal/atproto/identity" 26 "Coves/internal/core/blobs" 27 "Coves/internal/core/communities" 28 imageproxycore "Coves/internal/core/imageproxy" 29 "Coves/internal/db/postgres" 30) 31 32// TestImageProxy_E2E tests the complete image proxy flow including: 33// - Creating a community with an avatar 34// - Fetching the avatar via the image proxy 35// - Verifying response headers, status codes, and image dimensions 36// - Testing ETag-based caching (304 responses) 37// - Error handling for invalid presets and missing blobs 38func TestImageProxy_E2E(t *testing.T) { 39 if testing.Short() { 40 t.Skip("Skipping E2E integration test in short mode") 41 } 42 43 // Check if PDS is running 44 pdsURL := getTestPDSURL() 45 healthResp, err := http.Get(pdsURL + "/xrpc/_health") 46 if err != nil { 47 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 48 } 49 _ = healthResp.Body.Close() 50 51 // Setup test database 52 db := setupTestDB(t) 53 defer func() { _ = db.Close() }() 54 55 ctx := context.Background() 56 57 // Setup repositories and services 58 communityRepo := postgres.NewCommunityRepository(db) 59 60 // Setup identity resolver with local PLC 61 plcURL := os.Getenv("PLC_DIRECTORY_URL") 62 if plcURL == "" { 63 plcURL = "http://localhost:3002" 64 } 65 identityConfig := identity.DefaultConfig() 66 identityConfig.PLCURL = plcURL 67 identityResolver := identity.NewResolver(db, identityConfig) 68 69 // Create a real community WITH an avatar using the community service 70 // This ensures the blob is referenced by the community profile record 71 // (blobs must be referenced to be stored by PDS) 72 instanceDID := "did:web:coves.social" 73 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 74 blobService := blobs.NewBlobService(pdsURL) 75 76 communityService := communities.NewCommunityServiceWithPDSFactory( 77 communityRepo, 78 pdsURL, 79 instanceDID, 80 "coves.social", 81 provisioner, 82 nil, // No custom PDS factory 83 blobService, 84 ) 85 86 // Create avatar image data 87 avatarData := createTestImageForProxy(t, 200, 200, color.RGBA{R: 100, G: 150, B: 200, A: 255}) 88 89 uniqueID := time.Now().UnixNano() % 100000000 // Keep shorter for handle limit 90 communityName := fmt.Sprintf("ip%d", uniqueID) 91 creatorDID := fmt.Sprintf("did:plc:c%d", uniqueID) 92 93 t.Logf("Creating community with avatar: %s", communityName) 94 community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 95 Name: communityName, 96 DisplayName: "Image Proxy Test Community", 97 Description: "Testing image proxy with avatar", 98 Visibility: "public", 99 CreatedByDID: creatorDID, 100 HostedByDID: instanceDID, 101 AllowExternalDiscovery: true, 102 AvatarBlob: avatarData, 103 AvatarMimeType: "image/png", 104 }) 105 require.NoError(t, err, "Failed to create community with avatar") 106 107 // Get the avatar CID from the created community 108 avatarCID := community.AvatarCID 109 require.NotEmpty(t, avatarCID, "Avatar CID should not be empty") 110 t.Logf("Created community: DID=%s, AvatarCID=%s", community.DID, avatarCID) 111 112 // Verify blob exists on PDS before starting proxy tests 113 directBlobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsURL, community.DID, avatarCID) 114 t.Logf("Verifying blob exists at: %s", directBlobURL) 115 verifyResp, verifyErr := http.Get(directBlobURL) 116 if verifyErr != nil { 117 t.Logf("Warning: Failed to verify blob: %v", verifyErr) 118 } else { 119 t.Logf("Direct blob fetch status: %d", verifyResp.StatusCode) 120 if verifyResp.StatusCode != http.StatusOK { 121 var errBuf bytes.Buffer 122 _, _ = errBuf.ReadFrom(verifyResp.Body) 123 t.Logf("Direct blob fetch error: %s", errBuf.String()) 124 } 125 _ = verifyResp.Body.Close() 126 } 127 128 // Create the test server with image proxy routes 129 testServer := createImageProxyTestServer(t, pdsURL, identityResolver) 130 defer testServer.Close() 131 132 t.Run("fetch avatar via proxy returns valid JPEG", func(t *testing.T) { 133 // Build request URL 134 proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 135 t.Logf("Requesting: %s", proxyURL) 136 137 resp, err := http.Get(proxyURL) 138 require.NoError(t, err, "Request should succeed") 139 defer func() { _ = resp.Body.Close() }() 140 141 // Log error details if not 200 142 if resp.StatusCode != http.StatusOK { 143 var errBuf bytes.Buffer 144 _, _ = errBuf.ReadFrom(resp.Body) 145 t.Logf("Error response (status %d): %s", resp.StatusCode, errBuf.String()) 146 } 147 148 // Verify status code 149 assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 OK") 150 151 // Verify Content-Type header 152 contentType := resp.Header.Get("Content-Type") 153 assert.Equal(t, "image/jpeg", contentType, "Content-Type should be image/jpeg") 154 155 // Read the response body 156 var buf bytes.Buffer 157 _, err = buf.ReadFrom(resp.Body) 158 require.NoError(t, err, "Should read response body") 159 160 // Verify it's valid image data 161 img, err := imaging.Decode(&buf) 162 require.NoError(t, err, "Response should be valid image data") 163 164 // Verify dimensions match avatar_small preset (360x360) 165 bounds := img.Bounds() 166 assert.Equal(t, 360, bounds.Dx(), "Width should be 360 (avatar_small preset)") 167 assert.Equal(t, 360, bounds.Dy(), "Height should be 360 (avatar_small preset)") 168 169 t.Logf("Successfully fetched and verified avatar: %dx%d", bounds.Dx(), bounds.Dy()) 170 }) 171 172 t.Run("returns correct cache headers", func(t *testing.T) { 173 proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 174 175 resp, err := http.Get(proxyURL) 176 require.NoError(t, err, "Request should succeed") 177 defer func() { _ = resp.Body.Close() }() 178 179 // Verify Cache-Control header 180 cacheControl := resp.Header.Get("Cache-Control") 181 expectedCacheControl := "public, max-age=31536000, immutable" 182 assert.Equal(t, expectedCacheControl, cacheControl, "Cache-Control header should be correct") 183 184 // Verify ETag header is present and matches expected format 185 etag := resp.Header.Get("ETag") 186 expectedETag := fmt.Sprintf(`"avatar_small-%s"`, avatarCID) 187 assert.Equal(t, expectedETag, etag, "ETag should match preset-cid format") 188 189 t.Logf("Cache headers verified: Cache-Control=%s, ETag=%s", cacheControl, etag) 190 }) 191 192 t.Run("ETag returns 304 on match", func(t *testing.T) { 193 proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 194 195 // First, get the ETag 196 resp, err := http.Get(proxyURL) 197 require.NoError(t, err, "Initial request should succeed") 198 etag := resp.Header.Get("ETag") 199 _ = resp.Body.Close() 200 require.NotEmpty(t, etag, "ETag should be present") 201 202 // Now make a conditional request with If-None-Match 203 req, err := http.NewRequest(http.MethodGet, proxyURL, nil) 204 require.NoError(t, err, "Should create request") 205 req.Header.Set("If-None-Match", etag) 206 207 resp, err = http.DefaultClient.Do(req) 208 require.NoError(t, err, "Conditional request should succeed") 209 defer func() { _ = resp.Body.Close() }() 210 211 // Verify 304 Not Modified 212 assert.Equal(t, http.StatusNotModified, resp.StatusCode, "Should return 304 Not Modified") 213 214 // Verify no body in 304 response 215 var buf bytes.Buffer 216 _, _ = buf.ReadFrom(resp.Body) 217 assert.Equal(t, 0, buf.Len(), "304 response should have empty body") 218 219 t.Log("ETag conditional request correctly returned 304 Not Modified") 220 }) 221 222 t.Run("ETag mismatch returns full image", func(t *testing.T) { 223 proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 224 225 // Make request with non-matching ETag 226 req, err := http.NewRequest(http.MethodGet, proxyURL, nil) 227 require.NoError(t, err, "Should create request") 228 req.Header.Set("If-None-Match", `"wrong-etag-value"`) 229 230 resp, err := http.DefaultClient.Do(req) 231 require.NoError(t, err, "Request should succeed") 232 defer func() { _ = resp.Body.Close() }() 233 234 // Should return 200 with full image 235 assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 OK when ETag doesn't match") 236 237 // Verify body is present 238 var buf bytes.Buffer 239 _, _ = buf.ReadFrom(resp.Body) 240 assert.Greater(t, buf.Len(), 0, "Response should have body") 241 242 t.Log("Non-matching ETag correctly returned full image") 243 }) 244 245 t.Run("invalid preset returns 400", func(t *testing.T) { 246 proxyURL := fmt.Sprintf("%s/img/not_a_valid_preset/plain/%s/%s", testServer.URL, community.DID, avatarCID) 247 248 resp, err := http.Get(proxyURL) 249 require.NoError(t, err, "Request should succeed") 250 defer func() { _ = resp.Body.Close() }() 251 252 // Verify 400 Bad Request 253 assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Should return 400 for invalid preset") 254 255 // Verify error message 256 var buf bytes.Buffer 257 _, _ = buf.ReadFrom(resp.Body) 258 body := buf.String() 259 assert.Contains(t, body, "invalid preset", "Error message should mention invalid preset") 260 261 t.Logf("Invalid preset correctly returned 400: %s", body) 262 }) 263 264 t.Run("non-existent CID returns 404", func(t *testing.T) { 265 // Use a valid CIDv1 (raw codec, sha256) that doesn't exist on the PDS 266 // This is a properly formatted CID that will pass validation but won't exist 267 fakeCID := "bafkreiemeosfdll427qzow5tipvctigjebyvi6ketznqrau2ydhzyggt7i" 268 proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, fakeCID) 269 270 resp, err := http.Get(proxyURL) 271 require.NoError(t, err, "Request should succeed") 272 defer func() { _ = resp.Body.Close() }() 273 274 // Verify 404 Not Found (blob not found on PDS) 275 assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Should return 404 for non-existent CID") 276 277 t.Log("Non-existent CID correctly returned 404") 278 }) 279 280 t.Run("all valid presets work correctly", func(t *testing.T) { 281 // Test a subset of presets with fixed dimensions (cover fit) 282 presetTests := []struct { 283 preset string 284 expectWidth int 285 expectHeight int 286 }{ 287 {"avatar", 1000, 1000}, 288 {"avatar_small", 360, 360}, 289 // banner has 640x300 but input is 200x200, so it will be scaled+cropped 290 {"banner", 640, 300}, 291 // embed_thumbnail is 720x360, will also be scaled+cropped 292 {"embed_thumbnail", 720, 360}, 293 } 294 295 for _, tc := range presetTests { 296 t.Run(tc.preset, func(t *testing.T) { 297 proxyURL := fmt.Sprintf("%s/img/%s/plain/%s/%s", testServer.URL, tc.preset, community.DID, avatarCID) 298 299 resp, err := http.Get(proxyURL) 300 require.NoError(t, err, "Request should succeed for preset %s", tc.preset) 301 defer func() { _ = resp.Body.Close() }() 302 303 assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 for valid preset %s", tc.preset) 304 305 // Verify ETag format 306 etag := resp.Header.Get("ETag") 307 expectedETag := fmt.Sprintf(`"%s-%s"`, tc.preset, avatarCID) 308 assert.Equal(t, expectedETag, etag, "ETag should match for preset %s", tc.preset) 309 310 // Verify image dimensions 311 var buf bytes.Buffer 312 _, _ = buf.ReadFrom(resp.Body) 313 img, err := imaging.Decode(&buf) 314 require.NoError(t, err, "Should decode image for preset %s", tc.preset) 315 316 bounds := img.Bounds() 317 assert.Equal(t, tc.expectWidth, bounds.Dx(), "Width should match for preset %s", tc.preset) 318 assert.Equal(t, tc.expectHeight, bounds.Dy(), "Height should match for preset %s", tc.preset) 319 320 t.Logf("Preset %s: verified %dx%d", tc.preset, bounds.Dx(), bounds.Dy()) 321 }) 322 } 323 }) 324 325 t.Run("missing parameters return 400", func(t *testing.T) { 326 testCases := []struct { 327 name string 328 url string 329 }{ 330 {"missing CID", fmt.Sprintf("%s/img/avatar/plain/%s/", testServer.URL, community.DID)}, 331 {"missing DID", fmt.Sprintf("%s/img/avatar/plain//%s", testServer.URL, avatarCID)}, 332 } 333 334 for _, tc := range testCases { 335 t.Run(tc.name, func(t *testing.T) { 336 resp, err := http.Get(tc.url) 337 require.NoError(t, err, "Request should succeed") 338 defer func() { _ = resp.Body.Close() }() 339 340 // Should return 400 or 404 (depends on routing) 341 assert.True(t, resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusNotFound, 342 "Should return 400 or 404 for %s, got %d", tc.name, resp.StatusCode) 343 }) 344 } 345 }) 346 347 t.Run("content_preview preset preserves aspect ratio", func(t *testing.T) { 348 // content_preview uses FitContain which preserves aspect ratio 349 // Input is 200x200, max width is 800, so output should be 200x200 (no upscaling) 350 proxyURL := fmt.Sprintf("%s/img/content_preview/plain/%s/%s", testServer.URL, community.DID, avatarCID) 351 352 resp, err := http.Get(proxyURL) 353 require.NoError(t, err, "Request should succeed") 354 defer func() { _ = resp.Body.Close() }() 355 356 assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 357 358 var buf bytes.Buffer 359 _, _ = buf.ReadFrom(resp.Body) 360 img, err := imaging.Decode(&buf) 361 require.NoError(t, err, "Should decode image") 362 363 bounds := img.Bounds() 364 // content_preview with FitContain doesn't upscale, so 200x200 stays 200x200 365 assert.Equal(t, 200, bounds.Dx(), "Width should be preserved (no upscaling)") 366 assert.Equal(t, 200, bounds.Dy(), "Height should be preserved (no upscaling)") 367 368 t.Logf("content_preview preserved aspect ratio: %dx%d", bounds.Dx(), bounds.Dy()) 369 }) 370} 371 372// TestImageProxy_CacheHit tests that cache hits are faster than cache misses 373func TestImageProxy_CacheHit(t *testing.T) { 374 if testing.Short() { 375 t.Skip("Skipping cache test in short mode") 376 } 377 378 // Check if PDS is running 379 pdsURL := getTestPDSURL() 380 healthResp, err := http.Get(pdsURL + "/xrpc/_health") 381 if err != nil { 382 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 383 } 384 _ = healthResp.Body.Close() 385 386 // Setup test database 387 db := setupTestDB(t) 388 defer func() { _ = db.Close() }() 389 390 ctx := context.Background() 391 392 // Setup repositories and services 393 communityRepo := postgres.NewCommunityRepository(db) 394 395 // Setup identity resolver 396 plcURL := os.Getenv("PLC_DIRECTORY_URL") 397 if plcURL == "" { 398 plcURL = "http://localhost:3002" 399 } 400 identityConfig := identity.DefaultConfig() 401 identityConfig.PLCURL = plcURL 402 identityResolver := identity.NewResolver(db, identityConfig) 403 404 // Create a real community with avatar using the community service 405 instanceDID := "did:web:coves.social" 406 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 407 blobService := blobs.NewBlobService(pdsURL) 408 409 communityService := communities.NewCommunityServiceWithPDSFactory( 410 communityRepo, 411 pdsURL, 412 instanceDID, 413 "coves.social", 414 provisioner, 415 nil, 416 blobService, 417 ) 418 419 avatarData := createTestImageForProxy(t, 150, 150, color.RGBA{R: 50, G: 100, B: 150, A: 255}) 420 uniqueID := time.Now().UnixNano() % 100000000 // Keep shorter for handle limit 421 communityName := fmt.Sprintf("ic%d", uniqueID) 422 creatorDID := fmt.Sprintf("did:plc:cc%d", uniqueID) 423 424 community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 425 Name: communityName, 426 DisplayName: "Image Cache Test Community", 427 Description: "Testing image proxy caching", 428 Visibility: "public", 429 CreatedByDID: creatorDID, 430 HostedByDID: instanceDID, 431 AllowExternalDiscovery: true, 432 AvatarBlob: avatarData, 433 AvatarMimeType: "image/png", 434 }) 435 require.NoError(t, err, "Failed to create community with avatar") 436 437 avatarCID := community.AvatarCID 438 require.NotEmpty(t, avatarCID, "Avatar CID should not be empty") 439 t.Logf("Created community: DID=%s, AvatarCID=%s", community.DID, avatarCID) 440 441 // Create temp directory for cache 442 cacheDir := t.TempDir() 443 444 // Create test server with caching enabled 445 testServer := createImageProxyTestServerWithCache(t, pdsURL, identityResolver, cacheDir) 446 defer testServer.Close() 447 448 proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, community.DID, avatarCID) 449 450 // First request (cache miss) 451 startFirst := time.Now() 452 resp, err := http.Get(proxyURL) 453 require.NoError(t, err, "First request should succeed") 454 _ = resp.Body.Close() 455 firstDuration := time.Since(startFirst) 456 457 // Second request (should hit cache) 458 startSecond := time.Now() 459 resp, err = http.Get(proxyURL) 460 require.NoError(t, err, "Second request should succeed") 461 _ = resp.Body.Close() 462 secondDuration := time.Since(startSecond) 463 464 t.Logf("First request (cache miss): %v", firstDuration) 465 t.Logf("Second request (should hit cache): %v", secondDuration) 466 467 // Note: Cache hit should generally be faster, but timing can be flaky in tests 468 // So we just verify both requests succeed 469 assert.Equal(t, http.StatusOK, resp.StatusCode, "Cached request should return 200") 470} 471 472// createTestImageForProxy creates a test PNG image with specified dimensions and color 473func createTestImageForProxy(t *testing.T, width, height int, fillColor color.Color) []byte { 474 t.Helper() 475 476 img := image.NewRGBA(image.Rect(0, 0, width, height)) 477 for y := 0; y < height; y++ { 478 for x := 0; x < width; x++ { 479 img.Set(x, y, fillColor) 480 } 481 } 482 483 var buf bytes.Buffer 484 err := png.Encode(&buf, img) 485 require.NoError(t, err, "PNG encoding should succeed") 486 487 return buf.Bytes() 488} 489 490// createImageProxyTestServer creates an httptest server with image proxy routes configured 491func createImageProxyTestServer(t *testing.T, pdsURL string, identityResolver identity.Resolver) *httptest.Server { 492 t.Helper() 493 494 // Create temp directory for cache 495 cacheDir := t.TempDir() 496 return createImageProxyTestServerWithCache(t, pdsURL, identityResolver, cacheDir) 497} 498 499// createImageProxyTestServerWithCache creates an httptest server with image proxy routes and specified cache directory 500func createImageProxyTestServerWithCache(t *testing.T, pdsURL string, identityResolver identity.Resolver, cacheDir string) *httptest.Server { 501 t.Helper() 502 503 // Create imageproxy service components 504 cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 505 require.NoError(t, err, "Failed to create disk cache") // 1GB max 506 processor := imageproxycore.NewProcessor() 507 fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 508 config := imageproxycore.Config{ 509 Enabled: true, 510 CachePath: cacheDir, 511 CacheMaxGB: 1, 512 FetchTimeout: 30 * time.Second, 513 MaxSourceSizeMB: 10, 514 } 515 516 service, err := imageproxycore.NewService(cache, processor, fetcher, config) 517 require.NoError(t, err, "Failed to create imageproxy service") 518 519 // Create handler 520 handler := imageproxy.NewHandler(service, identityResolver) 521 522 // Create router and register routes 523 r := chi.NewRouter() 524 routes.RegisterImageProxyRoutes(r, handler) 525 526 return httptest.NewServer(r) 527} 528 529// TestImageProxy_MockPDS tests the image proxy with a mock PDS server 530// This allows testing image proxy behavior without a real PDS 531func TestImageProxy_MockPDS(t *testing.T) { 532 // Create test image 533 testImage := createTestImageForProxy(t, 100, 100, color.RGBA{R: 255, G: 128, B: 64, A: 255}) 534 testCID := "bafybeimockimagetest123" 535 testDID := "did:plc:mocktest123" 536 537 // Create mock PDS server that returns the test image 538 mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 539 // Verify it's a getBlob request 540 if !strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 541 w.WriteHeader(http.StatusNotFound) 542 return 543 } 544 545 // Check query parameters 546 did := r.URL.Query().Get("did") 547 cid := r.URL.Query().Get("cid") 548 549 if did == testDID && cid == testCID { 550 w.Header().Set("Content-Type", "image/png") 551 w.WriteHeader(http.StatusOK) 552 _, _ = w.Write(testImage) 553 return 554 } 555 556 // Return 404 for unknown blobs 557 w.WriteHeader(http.StatusNotFound) 558 })) 559 defer mockPDS.Close() 560 561 // Create mock identity resolver that returns the mock PDS URL 562 mockResolver := &mockIdentityResolverForImageProxy{ 563 pdsURL: mockPDS.URL, 564 } 565 566 // Create test server 567 cacheDir := t.TempDir() 568 cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 569 require.NoError(t, err, "Failed to create disk cache") 570 processor := imageproxycore.NewProcessor() 571 fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 572 config := imageproxycore.Config{ 573 Enabled: true, 574 CachePath: cacheDir, 575 CacheMaxGB: 1, 576 FetchTimeout: 30 * time.Second, 577 MaxSourceSizeMB: 10, 578 } 579 580 service, err := imageproxycore.NewService(cache, processor, fetcher, config) 581 require.NoError(t, err, "Failed to create imageproxy service") 582 handler := imageproxy.NewHandler(service, mockResolver) 583 584 r := chi.NewRouter() 585 routes.RegisterImageProxyRoutes(r, handler) 586 testServer := httptest.NewServer(r) 587 defer testServer.Close() 588 589 t.Run("mock PDS returns valid image", func(t *testing.T) { 590 proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, testCID) 591 592 resp, err := http.Get(proxyURL) 593 require.NoError(t, err, "Request should succeed") 594 defer func() { _ = resp.Body.Close() }() 595 596 assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 597 assert.Equal(t, "image/jpeg", resp.Header.Get("Content-Type"), "Content-Type should be JPEG") 598 599 // Verify processed dimensions (avatar is 1000x1000 per presets.go) 600 var buf bytes.Buffer 601 _, _ = buf.ReadFrom(resp.Body) 602 img, err := imaging.Decode(&buf) 603 require.NoError(t, err, "Should decode image") 604 605 bounds := img.Bounds() 606 assert.Equal(t, 1000, bounds.Dx(), "Width should be 1000 (avatar preset)") 607 assert.Equal(t, 1000, bounds.Dy(), "Height should be 1000 (avatar preset)") 608 }) 609 610 t.Run("mock PDS 404 returns proxy 404", func(t *testing.T) { 611 proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, "nonexistentcid") 612 613 resp, err := http.Get(proxyURL) 614 require.NoError(t, err, "Request should succeed") 615 defer func() { _ = resp.Body.Close() }() 616 617 assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Should return 404") 618 }) 619} 620 621// mockIdentityResolverForImageProxy is a mock identity resolver for testing 622type mockIdentityResolverForImageProxy struct { 623 pdsURL string 624} 625 626func (m *mockIdentityResolverForImageProxy) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 627 return nil, fmt.Errorf("not implemented") 628} 629 630func (m *mockIdentityResolverForImageProxy) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 631 return "", "", fmt.Errorf("not implemented") 632} 633 634func (m *mockIdentityResolverForImageProxy) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 635 return &identity.DIDDocument{ 636 DID: did, 637 Service: []identity.Service{ 638 { 639 ID: "#atproto_pds", 640 Type: "AtprotoPersonalDataServer", 641 ServiceEndpoint: m.pdsURL, 642 }, 643 }, 644 }, nil 645} 646 647func (m *mockIdentityResolverForImageProxy) Purge(ctx context.Context, identifier string) error { 648 return nil 649} 650 651// TestImageProxy_ErrorHandling tests various error conditions 652func TestImageProxy_ErrorHandling(t *testing.T) { 653 // Create mock identity resolver 654 mockResolver := &mockIdentityResolverForImageProxy{ 655 pdsURL: "http://localhost:9999", // Non-existent server 656 } 657 658 // Create test server 659 cacheDir := t.TempDir() 660 cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 661 require.NoError(t, err, "Failed to create disk cache") 662 processor := imageproxycore.NewProcessor() 663 fetcher := imageproxycore.NewPDSFetcher(1 * time.Second, 10) // Short timeout 664 config := imageproxycore.Config{ 665 Enabled: true, 666 CachePath: cacheDir, 667 CacheMaxGB: 1, 668 FetchTimeout: 1 * time.Second, 669 MaxSourceSizeMB: 10, 670 } 671 672 service, err := imageproxycore.NewService(cache, processor, fetcher, config) 673 require.NoError(t, err, "Failed to create imageproxy service") 674 handler := imageproxy.NewHandler(service, mockResolver) 675 676 r := chi.NewRouter() 677 routes.RegisterImageProxyRoutes(r, handler) 678 testServer := httptest.NewServer(r) 679 defer testServer.Close() 680 681 t.Run("connection refused returns 502", func(t *testing.T) { 682 // Use a valid CID format - this will pass validation but fail at the PDS fetch stage 683 validCID := "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 684 proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", testServer.URL, validCID) 685 686 resp, err := http.Get(proxyURL) 687 require.NoError(t, err, "Request should succeed") 688 defer func() { _ = resp.Body.Close() }() 689 690 // Should return 502 Bad Gateway when PDS fetch fails 691 assert.Equal(t, http.StatusBadGateway, resp.StatusCode, "Should return 502 when PDS is unreachable") 692 }) 693 694 t.Run("invalid DID resolution returns 502", func(t *testing.T) { 695 // Create resolver that returns error 696 errorResolver := &errorMockResolver{} 697 698 errorHandler := imageproxy.NewHandler(service, errorResolver) 699 errorRouter := chi.NewRouter() 700 routes.RegisterImageProxyRoutes(errorRouter, errorHandler) 701 errorServer := httptest.NewServer(errorRouter) 702 defer errorServer.Close() 703 704 // Use a valid CID format - this will pass validation but fail at DID resolution 705 validCID := "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 706 proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", errorServer.URL, validCID) 707 708 resp, err := http.Get(proxyURL) 709 require.NoError(t, err, "Request should succeed") 710 defer func() { _ = resp.Body.Close() }() 711 712 assert.Equal(t, http.StatusBadGateway, resp.StatusCode, "Should return 502 when DID resolution fails") 713 }) 714} 715 716// errorMockResolver is a mock resolver that always returns an error 717type errorMockResolver struct{} 718 719func (m *errorMockResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 720 return nil, fmt.Errorf("resolution failed") 721} 722 723func (m *errorMockResolver) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 724 return "", "", fmt.Errorf("resolution failed") 725} 726 727func (m *errorMockResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 728 return nil, fmt.Errorf("resolution failed") 729} 730 731func (m *errorMockResolver) Purge(ctx context.Context, identifier string) error { 732 return nil 733} 734 735// TestImageProxy_UnsupportedFormat tests behavior with unsupported image formats 736func TestImageProxy_UnsupportedFormat(t *testing.T) { 737 // Create mock PDS that returns invalid image data 738 mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 739 if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 740 cid := r.URL.Query().Get("cid") 741 742 if cid == "textdata" { 743 // Return text data instead of image 744 w.Header().Set("Content-Type", "text/plain") 745 w.WriteHeader(http.StatusOK) 746 _, _ = w.Write([]byte("this is not an image")) 747 return 748 } 749 750 if cid == "corruptedimage" { 751 // Return corrupted image data 752 w.Header().Set("Content-Type", "image/png") 753 w.WriteHeader(http.StatusOK) 754 _, _ = w.Write([]byte{0x89, 0x50, 0x4E, 0x47, 0x00, 0x00}) // Incomplete PNG header 755 return 756 } 757 758 if cid == "emptybody" { 759 // Return empty body 760 w.WriteHeader(http.StatusOK) 761 return 762 } 763 } 764 w.WriteHeader(http.StatusNotFound) 765 })) 766 defer mockPDS.Close() 767 768 mockResolver := &mockIdentityResolverForImageProxy{ 769 pdsURL: mockPDS.URL, 770 } 771 772 cacheDir := t.TempDir() 773 cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 774 require.NoError(t, err, "Failed to create disk cache") 775 processor := imageproxycore.NewProcessor() 776 fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 777 config := imageproxycore.DefaultConfig() 778 779 service, err := imageproxycore.NewService(cache, processor, fetcher, config) 780 require.NoError(t, err, "Failed to create imageproxy service") 781 handler := imageproxy.NewHandler(service, mockResolver) 782 783 r := chi.NewRouter() 784 routes.RegisterImageProxyRoutes(r, handler) 785 testServer := httptest.NewServer(r) 786 defer testServer.Close() 787 788 testCases := []struct { 789 name string 790 cid string 791 expectedStatus int 792 }{ 793 {"text data", "textdata", http.StatusBadRequest}, 794 {"corrupted image", "corruptedimage", http.StatusInternalServerError}, 795 {"empty body", "emptybody", http.StatusBadRequest}, 796 } 797 798 for _, tc := range testCases { 799 t.Run(tc.name, func(t *testing.T) { 800 proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", testServer.URL, tc.cid) 801 802 resp, err := http.Get(proxyURL) 803 require.NoError(t, err, "Request should succeed") 804 defer func() { _ = resp.Body.Close() }() 805 806 // Should return error status for invalid image data 807 assert.True(t, resp.StatusCode >= 400, "Should return error status for %s", tc.name) 808 t.Logf("%s returned status %d", tc.name, resp.StatusCode) 809 }) 810 } 811} 812 813// TestImageProxy_LargeImage tests behavior with large images 814func TestImageProxy_LargeImage(t *testing.T) { 815 // Create a large test image (1000x1000) 816 largeImage := createTestImageForProxy(t, 1000, 1000, color.RGBA{R: 200, G: 100, B: 50, A: 255}) 817 testCID := "bafylargeimagecid" 818 testDID := "did:plc:largetest" 819 820 mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 821 if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 822 did := r.URL.Query().Get("did") 823 cid := r.URL.Query().Get("cid") 824 825 if did == testDID && cid == testCID { 826 w.Header().Set("Content-Type", "image/png") 827 w.WriteHeader(http.StatusOK) 828 _, _ = w.Write(largeImage) 829 return 830 } 831 } 832 w.WriteHeader(http.StatusNotFound) 833 })) 834 defer mockPDS.Close() 835 836 mockResolver := &mockIdentityResolverForImageProxy{ 837 pdsURL: mockPDS.URL, 838 } 839 840 cacheDir := t.TempDir() 841 cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 842 require.NoError(t, err, "Failed to create disk cache") 843 processor := imageproxycore.NewProcessor() 844 fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 845 config := imageproxycore.DefaultConfig() 846 847 service, err := imageproxycore.NewService(cache, processor, fetcher, config) 848 require.NoError(t, err, "Failed to create imageproxy service") 849 handler := imageproxy.NewHandler(service, mockResolver) 850 851 r := chi.NewRouter() 852 routes.RegisterImageProxyRoutes(r, handler) 853 testServer := httptest.NewServer(r) 854 defer testServer.Close() 855 856 t.Run("large image resized correctly", func(t *testing.T) { 857 proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, testCID) 858 859 resp, err := http.Get(proxyURL) 860 require.NoError(t, err, "Request should succeed") 861 defer func() { _ = resp.Body.Close() }() 862 863 assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 864 865 var buf bytes.Buffer 866 _, _ = buf.ReadFrom(resp.Body) 867 img, err := imaging.Decode(&buf) 868 require.NoError(t, err, "Should decode image") 869 870 bounds := img.Bounds() 871 assert.Equal(t, 1000, bounds.Dx(), "Width should be 1000 (avatar preset)") 872 assert.Equal(t, 1000, bounds.Dy(), "Height should be 1000 (avatar preset)") 873 874 t.Logf("Large image correctly resized to %dx%d", bounds.Dx(), bounds.Dy()) 875 }) 876 877 t.Run("content_preview limits width for large image", func(t *testing.T) { 878 proxyURL := fmt.Sprintf("%s/img/content_preview/plain/%s/%s", testServer.URL, testDID, testCID) 879 880 resp, err := http.Get(proxyURL) 881 require.NoError(t, err, "Request should succeed") 882 defer func() { _ = resp.Body.Close() }() 883 884 assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 885 886 var buf bytes.Buffer 887 _, _ = buf.ReadFrom(resp.Body) 888 img, err := imaging.Decode(&buf) 889 require.NoError(t, err, "Should decode image") 890 891 bounds := img.Bounds() 892 // content_preview max width is 800, preserves aspect ratio 893 assert.Equal(t, 800, bounds.Dx(), "Width should be limited to 800") 894 assert.Equal(t, 800, bounds.Dy(), "Height should be 800 (1:1 aspect ratio)") 895 896 t.Logf("Large image correctly scaled to %dx%d for content_preview", bounds.Dx(), bounds.Dy()) 897 }) 898} 899 900// TestImageProxy_ResponseJSON verifies no JSON is returned (should be plain text or image) 901func TestImageProxy_ResponseJSON(t *testing.T) { 902 mockResolver := &mockIdentityResolverForImageProxy{ 903 pdsURL: "http://localhost:9999", 904 } 905 906 cacheDir := t.TempDir() 907 cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 908 require.NoError(t, err, "Failed to create disk cache") 909 processor := imageproxycore.NewProcessor() 910 fetcher := imageproxycore.NewPDSFetcher(1 * time.Second, 10) 911 config := imageproxycore.DefaultConfig() 912 913 service, err := imageproxycore.NewService(cache, processor, fetcher, config) 914 require.NoError(t, err, "Failed to create imageproxy service") 915 handler := imageproxy.NewHandler(service, mockResolver) 916 917 r := chi.NewRouter() 918 routes.RegisterImageProxyRoutes(r, handler) 919 testServer := httptest.NewServer(r) 920 defer testServer.Close() 921 922 t.Run("error responses are plain text not JSON", func(t *testing.T) { 923 proxyURL := fmt.Sprintf("%s/img/invalid_preset/plain/did:plc:test/cid", testServer.URL) 924 925 resp, err := http.Get(proxyURL) 926 require.NoError(t, err, "Request should succeed") 927 defer func() { _ = resp.Body.Close() }() 928 929 contentType := resp.Header.Get("Content-Type") 930 assert.Contains(t, contentType, "text/plain", "Error responses should be text/plain") 931 932 // Verify body is not valid JSON 933 var buf bytes.Buffer 934 _, _ = buf.ReadFrom(resp.Body) 935 body := buf.Bytes() 936 937 var jsonCheck map[string]interface{} 938 jsonErr := json.Unmarshal(body, &jsonCheck) 939 assert.Error(t, jsonErr, "Error response should not be valid JSON") 940 941 t.Logf("Error response correctly returned as plain text: %s", string(body)) 942 }) 943}