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}