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}