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