A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10
11 "Coves/internal/api/handlers/post"
12 "Coves/internal/api/middleware"
13 "Coves/internal/core/communities"
14 "Coves/internal/core/posts"
15 "Coves/internal/db/postgres"
16
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19)
20
21// TestPostHandler_SecurityValidation tests HTTP handler-level security checks
22func TestPostHandler_SecurityValidation(t *testing.T) {
23 if testing.Short() {
24 t.Skip("Skipping integration test in short mode")
25 }
26
27 db := setupTestDB(t)
28 defer func() {
29 if err := db.Close(); err != nil {
30 t.Logf("Failed to close database: %v", err)
31 }
32 }()
33
34 // Setup services
35 communityRepo := postgres.NewCommunityRepository(db)
36 communityService := communities.NewCommunityService(
37 communityRepo,
38 "http://localhost:3001",
39 "did:web:test.coves.social",
40 "test.coves.social",
41 nil,
42 )
43
44 postRepo := postgres.NewPostRepository(db)
45 postService := posts.NewPostService(postRepo, communityService, nil, "http://localhost:3001") // nil aggregatorService for user-only tests
46
47 // Create handler
48 handler := post.NewCreateHandler(postService)
49
50 t.Run("Reject client-provided authorDid", func(t *testing.T) {
51 // Client tries to impersonate another user
52 payload := map[string]interface{}{
53 "community": "did:plc:test123",
54 "authorDid": "did:plc:attacker", // ❌ Client trying to set author
55 "content": "Malicious post",
56 }
57
58 body, _ := json.Marshal(payload)
59 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
60
61 // Mock authenticated user context
62 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
63 req = req.WithContext(ctx)
64
65 rec := httptest.NewRecorder()
66 handler.HandleCreate(rec, req)
67
68 // Should return 400 Bad Request
69 assert.Equal(t, http.StatusBadRequest, rec.Code)
70
71 var errResp map[string]interface{}
72 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
73 require.NoError(t, err)
74
75 assert.Equal(t, "InvalidRequest", errResp["error"])
76 assert.Contains(t, errResp["message"], "authorDid must not be provided")
77 })
78
79 t.Run("Reject missing authentication", func(t *testing.T) {
80 payload := map[string]interface{}{
81 "community": "did:plc:test123",
82 "content": "Test post",
83 }
84
85 body, _ := json.Marshal(payload)
86 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
87
88 // No auth context set
89 rec := httptest.NewRecorder()
90 handler.HandleCreate(rec, req)
91
92 // Should return 401 Unauthorized
93 assert.Equal(t, http.StatusUnauthorized, rec.Code)
94
95 var errResp map[string]interface{}
96 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
97 require.NoError(t, err)
98
99 assert.Equal(t, "AuthRequired", errResp["error"])
100 })
101
102 t.Run("Reject request body > 1MB", func(t *testing.T) {
103 // Create a payload larger than 1MB
104 largeContent := strings.Repeat("A", 1*1024*1024+1000) // 1MB + 1KB
105
106 payload := map[string]interface{}{
107 "community": "did:plc:test123",
108 "content": largeContent,
109 }
110
111 body, _ := json.Marshal(payload)
112 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
113
114 // Mock authenticated user context
115 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
116 req = req.WithContext(ctx)
117
118 rec := httptest.NewRecorder()
119 handler.HandleCreate(rec, req)
120
121 // Should return 413 Request Entity Too Large
122 assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
123
124 var errResp map[string]interface{}
125 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
126 require.NoError(t, err)
127
128 assert.Equal(t, "RequestTooLarge", errResp["error"])
129 })
130
131 t.Run("Reject malformed JSON", func(t *testing.T) {
132 // Invalid JSON
133 invalidJSON := []byte(`{"community": "did:plc:test123", "content": `)
134
135 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(invalidJSON))
136
137 // Mock authenticated user context
138 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
139 req = req.WithContext(ctx)
140
141 rec := httptest.NewRecorder()
142 handler.HandleCreate(rec, req)
143
144 // Should return 400 Bad Request
145 assert.Equal(t, http.StatusBadRequest, rec.Code)
146
147 var errResp map[string]interface{}
148 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
149 require.NoError(t, err)
150
151 assert.Equal(t, "InvalidRequest", errResp["error"])
152 })
153
154 t.Run("Reject empty community field", func(t *testing.T) {
155 payload := map[string]interface{}{
156 "community": "", // Empty community
157 "content": "Test post",
158 }
159
160 body, _ := json.Marshal(payload)
161 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
162
163 // Mock authenticated user context
164 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
165 req = req.WithContext(ctx)
166
167 rec := httptest.NewRecorder()
168 handler.HandleCreate(rec, req)
169
170 // Should return 400 Bad Request
171 assert.Equal(t, http.StatusBadRequest, rec.Code)
172
173 var errResp map[string]interface{}
174 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
175 require.NoError(t, err)
176
177 assert.Equal(t, "InvalidRequest", errResp["error"])
178 assert.Contains(t, errResp["message"], "community is required")
179 })
180
181 t.Run("Reject invalid at-identifier format", func(t *testing.T) {
182 invalidIdentifiers := []string{
183 "not-a-did-or-handle",
184 "just-plain-text",
185 "http://example.com",
186 }
187
188 for _, invalidID := range invalidIdentifiers {
189 t.Run(invalidID, func(t *testing.T) {
190 payload := map[string]interface{}{
191 "community": invalidID,
192 "content": "Test post",
193 }
194
195 body, _ := json.Marshal(payload)
196 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
197
198 // Mock authenticated user context
199 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
200 req = req.WithContext(ctx)
201
202 rec := httptest.NewRecorder()
203 handler.HandleCreate(rec, req)
204
205 // Should reject (either 400 InvalidRequest or 404 NotFound depending on how service resolves it)
206 // Both are valid - the important thing is that it rejects invalid identifiers
207 assert.True(t, rec.Code == http.StatusBadRequest || rec.Code == http.StatusNotFound,
208 "Should reject invalid identifier with 400 or 404, got %d", rec.Code)
209
210 var errResp map[string]interface{}
211 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
212 require.NoError(t, err)
213
214 // Should have an error type and message
215 assert.NotEmpty(t, errResp["error"], "should have error type")
216 assert.NotEmpty(t, errResp["message"], "should have error message")
217 })
218 }
219 })
220
221 t.Run("Accept valid DID format", func(t *testing.T) {
222 validDIDs := []string{
223 "did:plc:test123",
224 "did:web:example.com",
225 }
226
227 for _, validDID := range validDIDs {
228 t.Run(validDID, func(t *testing.T) {
229 payload := map[string]interface{}{
230 "community": validDID,
231 "content": "Test post",
232 }
233
234 body, _ := json.Marshal(payload)
235 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
236
237 // Mock authenticated user context
238 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
239 req = req.WithContext(ctx)
240
241 rec := httptest.NewRecorder()
242 handler.HandleCreate(rec, req)
243
244 // May fail at service layer (community not found), but should NOT fail at validation
245 // Looking for anything OTHER than "community must be a DID" error
246 if rec.Code == http.StatusBadRequest {
247 var errResp map[string]interface{}
248 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
249 require.NoError(t, err)
250
251 // Should NOT be the format validation error
252 assert.NotContains(t, errResp["message"], "community must be a DID")
253 }
254 })
255 }
256 })
257
258 t.Run("Accept valid scoped handle format", func(t *testing.T) {
259 // Scoped format: !name@instance (gets converted to name.community.instance internally)
260 validScopedHandles := []string{
261 "!mycommunity@bsky.social", // Scoped format
262 "!gaming@test.coves.social", // Scoped format
263 }
264
265 for _, validHandle := range validScopedHandles {
266 t.Run(validHandle, func(t *testing.T) {
267 payload := map[string]interface{}{
268 "community": validHandle,
269 "content": "Test post",
270 }
271
272 body, _ := json.Marshal(payload)
273 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
274
275 // Mock authenticated user context
276 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
277 req = req.WithContext(ctx)
278
279 rec := httptest.NewRecorder()
280 handler.HandleCreate(rec, req)
281
282 // May fail at service layer (community not found), but should NOT fail at format validation
283 if rec.Code == http.StatusBadRequest {
284 var errResp map[string]interface{}
285 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
286 require.NoError(t, err)
287
288 // Should NOT be the format validation error
289 assert.NotContains(t, errResp["message"], "community must be a DID")
290 assert.NotContains(t, errResp["message"], "scoped handle must include")
291 }
292 })
293 }
294 })
295
296 t.Run("Accept valid canonical handle format", func(t *testing.T) {
297 // Canonical format: name.community.instance (DNS-resolvable atProto handle)
298 validCanonicalHandles := []string{
299 "gaming.community.test.coves.social",
300 "books.community.bsky.social",
301 }
302
303 for _, validHandle := range validCanonicalHandles {
304 t.Run(validHandle, func(t *testing.T) {
305 payload := map[string]interface{}{
306 "community": validHandle,
307 "content": "Test post",
308 }
309
310 body, _ := json.Marshal(payload)
311 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
312
313 // Mock authenticated user context
314 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
315 req = req.WithContext(ctx)
316
317 rec := httptest.NewRecorder()
318 handler.HandleCreate(rec, req)
319
320 // May fail at service layer (community not found), but should NOT fail at format validation
321 // Canonical handles don't have strict validation at handler level - they're validated by the service
322 if rec.Code == http.StatusBadRequest {
323 var errResp map[string]interface{}
324 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
325 require.NoError(t, err)
326
327 // Should NOT be the format validation error (canonical handles pass basic validation)
328 assert.NotContains(t, errResp["message"], "community must be a DID")
329 }
330 })
331 }
332 })
333
334 t.Run("Accept valid @-prefixed handle format", func(t *testing.T) {
335 // @-prefixed format: @name.community.instance (atProto standard, @ gets stripped)
336 validAtHandles := []string{
337 "@gaming.community.test.coves.social",
338 "@books.community.bsky.social",
339 }
340
341 for _, validHandle := range validAtHandles {
342 t.Run(validHandle, func(t *testing.T) {
343 payload := map[string]interface{}{
344 "community": validHandle,
345 "content": "Test post",
346 }
347
348 body, _ := json.Marshal(payload)
349 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
350
351 // Mock authenticated user context
352 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
353 req = req.WithContext(ctx)
354
355 rec := httptest.NewRecorder()
356 handler.HandleCreate(rec, req)
357
358 // May fail at service layer (community not found), but should NOT fail at format validation
359 // @ prefix is valid and gets stripped by the resolver
360 if rec.Code == http.StatusBadRequest {
361 var errResp map[string]interface{}
362 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
363 require.NoError(t, err)
364
365 // Should NOT be the format validation error
366 assert.NotContains(t, errResp["message"], "community must be a DID")
367 }
368 })
369 }
370 })
371
372 t.Run("Reject non-POST methods", func(t *testing.T) {
373 methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch}
374
375 for _, method := range methods {
376 t.Run(method, func(t *testing.T) {
377 req := httptest.NewRequest(method, "/xrpc/social.coves.community.post.create", nil)
378 rec := httptest.NewRecorder()
379
380 handler.HandleCreate(rec, req)
381
382 // Should return 405 Method Not Allowed
383 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
384 })
385 }
386 })
387}
388
389// TestPostHandler_SpecialCharacters tests content with special characters
390func TestPostHandler_SpecialCharacters(t *testing.T) {
391 if testing.Short() {
392 t.Skip("Skipping integration test in short mode")
393 }
394
395 db := setupTestDB(t)
396 defer func() {
397 if err := db.Close(); err != nil {
398 t.Logf("Failed to close database: %v", err)
399 }
400 }()
401
402 // Setup services
403 communityRepo := postgres.NewCommunityRepository(db)
404 communityService := communities.NewCommunityService(
405 communityRepo,
406 "http://localhost:3001",
407 "did:web:test.coves.social",
408 "test.coves.social",
409 nil,
410 )
411
412 postRepo := postgres.NewPostRepository(db)
413 postService := posts.NewPostService(postRepo, communityService, nil, "http://localhost:3001") // nil aggregatorService for user-only tests
414
415 handler := post.NewCreateHandler(postService)
416
417 t.Run("Accept Unicode and emoji", func(t *testing.T) {
418 content := "Hello 世界! 🌍 Testing unicode: café, naïve, Ω"
419
420 payload := map[string]interface{}{
421 "community": "did:plc:test123",
422 "content": content,
423 }
424
425 body, _ := json.Marshal(payload)
426 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
427
428 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
429 req = req.WithContext(ctx)
430
431 rec := httptest.NewRecorder()
432 handler.HandleCreate(rec, req)
433
434 // Should NOT reject due to unicode/special characters
435 // May fail at service layer for other reasons, but should pass handler validation
436 assert.NotEqual(t, http.StatusBadRequest, rec.Code, "Handler should not reject valid unicode")
437 })
438
439 t.Run("SQL injection attempt is safely handled", func(t *testing.T) {
440 // Common SQL injection patterns
441 sqlInjections := []string{
442 "'; DROP TABLE posts; --",
443 "1' OR '1'='1",
444 "<script>alert('xss')</script>",
445 "../../../etc/passwd",
446 }
447
448 for _, injection := range sqlInjections {
449 t.Run(injection, func(t *testing.T) {
450 payload := map[string]interface{}{
451 "community": "did:plc:test123",
452 "content": injection,
453 }
454
455 body, _ := json.Marshal(payload)
456 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
457
458 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
459 req = req.WithContext(ctx)
460
461 rec := httptest.NewRecorder()
462 handler.HandleCreate(rec, req)
463
464 // Handler should NOT crash or return 500
465 // These are just strings, should be handled safely
466 assert.NotEqual(t, http.StatusInternalServerError, rec.Code,
467 "Handler should not crash on injection attempt")
468 })
469 }
470 })
471}
472
473// TestPostService_DIDValidationSecurity tests service-layer DID validation (defense-in-depth)
474func TestPostService_DIDValidationSecurity(t *testing.T) {
475 if testing.Short() {
476 t.Skip("Skipping integration test in short mode")
477 }
478
479 db := setupTestDB(t)
480 defer func() {
481 if err := db.Close(); err != nil {
482 t.Logf("Failed to close database: %v", err)
483 }
484 }()
485
486 // Setup services
487 communityRepo := postgres.NewCommunityRepository(db)
488 communityService := communities.NewCommunityService(
489 communityRepo,
490 "http://localhost:3001",
491 "did:web:test.coves.social",
492 "test.coves.social",
493 nil,
494 )
495
496 postRepo := postgres.NewPostRepository(db)
497 postService := posts.NewPostService(postRepo, communityService, nil, "http://localhost:3001")
498
499 t.Run("Reject posts when context DID is missing", func(t *testing.T) {
500 // Simulate bypassing handler - no DID in context
501 req := httptest.NewRequest(http.MethodPost, "/", nil)
502 ctx := middleware.SetTestUserDID(req.Context(), "") // Empty DID
503
504 content := "Test post"
505 postReq := posts.CreatePostRequest{
506 Community: "did:plc:test123",
507 AuthorDID: "did:plc:alice",
508 Content: &content,
509 }
510
511 _, err := postService.CreatePost(ctx, postReq)
512
513 // Should fail with authentication error
514 assert.Error(t, err)
515 assert.Contains(t, strings.ToLower(err.Error()), "authenticated")
516 })
517
518 t.Run("Reject posts when request DID doesn't match context DID", func(t *testing.T) {
519 // SECURITY TEST: This prevents DID spoofing attacks
520 // Simulates attack where handler is bypassed or compromised
521 req := httptest.NewRequest(http.MethodPost, "/", nil)
522 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") // Authenticated as Alice
523
524 content := "Spoofed post"
525 postReq := posts.CreatePostRequest{
526 Community: "did:plc:test123",
527 AuthorDID: "did:plc:bob", // ❌ Trying to post as Bob!
528 Content: &content,
529 }
530
531 _, err := postService.CreatePost(ctx, postReq)
532
533 // Should fail with DID mismatch error
534 assert.Error(t, err)
535 assert.Contains(t, strings.ToLower(err.Error()), "does not match")
536 })
537
538 t.Run("Accept posts when request DID matches context DID", func(t *testing.T) {
539 req := httptest.NewRequest(http.MethodPost, "/", nil)
540 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") // Authenticated as Alice
541
542 content := "Valid post"
543 postReq := posts.CreatePostRequest{
544 Community: "did:plc:test123",
545 AuthorDID: "did:plc:alice", // ✓ Matching DID
546 Content: &content,
547 }
548
549 _, err := postService.CreatePost(ctx, postReq)
550 // May fail for other reasons (community not found), but NOT due to DID mismatch
551 if err != nil {
552 assert.NotContains(t, strings.ToLower(err.Error()), "does not match",
553 "Should not fail due to DID mismatch when DIDs match")
554 }
555 })
556}