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