A community based topic aggregation platform built on atproto
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}