A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/atproto/jetstream" 5 "Coves/internal/db/postgres" 6 "context" 7 "fmt" 8 "net/http" 9 "net/http/httptest" 10 "strings" 11 "testing" 12 "time" 13) 14 15// TestHostedByVerification_DomainMatching tests that hostedBy domain must match handle domain 16func TestHostedByVerification_DomainMatching(t *testing.T) { 17 db := setupTestDB(t) 18 defer func() { 19 if err := db.Close(); err != nil { 20 t.Logf("Failed to close database: %v", err) 21 } 22 }() 23 24 repo := postgres.NewCommunityRepository(db) 25 ctx := context.Background() 26 27 t.Run("rejects community with mismatched hostedBy domain", func(t *testing.T) { 28 // Create consumer with verification enabled 29 // Pass nil for identity resolver - not needed since consumer constructs handles from DIDs 30 consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false, nil) 31 32 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 33 communityDID := generateTestDID(uniqueSuffix) 34 uniqueHandle := fmt.Sprintf("gaming%s.community.coves.social", uniqueSuffix) 35 36 // Attempt to create community claiming to be hosted by nintendo.com 37 // but with a coves.social handle (ATTACK!) 38 event := &jetstream.JetstreamEvent{ 39 Did: communityDID, 40 TimeUS: time.Now().UnixMicro(), 41 Kind: "commit", 42 Commit: &jetstream.CommitEvent{ 43 Rev: "rev123", 44 Operation: "create", 45 Collection: "social.coves.community.profile", 46 RKey: "self", 47 CID: "bafy123abc", 48 Record: map[string]interface{}{ 49 "handle": uniqueHandle, // coves.social handle 50 "name": "gaming", 51 "displayName": "Nintendo Gaming", 52 "description": "Fake Nintendo community", 53 "createdBy": "did:plc:attacker123", 54 "hostedBy": "did:web:nintendo.com", // ← SPOOFED! Claiming Nintendo hosting 55 "visibility": "public", 56 "federation": map[string]interface{}{ 57 "allowExternalDiscovery": true, 58 }, 59 "memberCount": 0, 60 "subscriberCount": 0, 61 "createdAt": time.Now().Format(time.RFC3339), 62 }, 63 }, 64 } 65 66 // This should fail verification 67 err := consumer.HandleEvent(ctx, event) 68 if err == nil { 69 t.Fatal("Expected verification error for mismatched hostedBy domain, got nil") 70 } 71 72 // Verify error message mentions domain mismatch 73 errMsg := err.Error() 74 if errMsg == "" { 75 t.Fatal("Expected error message, got empty string") 76 } 77 t.Logf("Got expected error: %v", err) 78 79 // Verify community was NOT indexed 80 _, getErr := repo.GetByDID(ctx, communityDID) 81 if getErr == nil { 82 t.Fatal("Community should not have been indexed, but was found in database") 83 } 84 }) 85 86 t.Run("accepts community with matching hostedBy domain", func(t *testing.T) { 87 // Create consumer with verification DISABLED for this test 88 // This test focuses on domain matching logic only 89 // Full bidirectional verification is tested separately with mock HTTP server 90 consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", true, nil) 91 92 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 93 communityDID := generateTestDID(uniqueSuffix) 94 uniqueHandle := fmt.Sprintf("gaming%s.community.coves.social", uniqueSuffix) 95 96 // Create community with matching hostedBy and handle domains 97 event := &jetstream.JetstreamEvent{ 98 Did: communityDID, 99 TimeUS: time.Now().UnixMicro(), 100 Kind: "commit", 101 Commit: &jetstream.CommitEvent{ 102 Rev: "rev123", 103 Operation: "create", 104 Collection: "social.coves.community.profile", 105 RKey: "self", 106 CID: "bafy123abc", 107 Record: map[string]interface{}{ 108 "handle": uniqueHandle, // coves.social handle 109 "name": "gaming", 110 "displayName": "Gaming Community", 111 "description": "Legitimate coves.social community", 112 "createdBy": "did:plc:user123", 113 "hostedBy": "did:web:coves.social", // ✅ Matches handle domain 114 "visibility": "public", 115 "federation": map[string]interface{}{ 116 "allowExternalDiscovery": true, 117 }, 118 "memberCount": 0, 119 "subscriberCount": 0, 120 "createdAt": time.Now().Format(time.RFC3339), 121 }, 122 }, 123 } 124 125 // This should succeed (domain matching passes, DID verification skipped) 126 err := consumer.HandleEvent(ctx, event) 127 if err != nil { 128 t.Fatalf("Expected verification to succeed, got error: %v", err) 129 } 130 131 // Verify community was indexed 132 community, getErr := repo.GetByDID(ctx, communityDID) 133 if getErr != nil { 134 t.Fatalf("Community should have been indexed: %v", getErr) 135 } 136 if community.HostedByDID != "did:web:coves.social" { 137 t.Errorf("Expected hostedByDID 'did:web:coves.social', got '%s'", community.HostedByDID) 138 } 139 }) 140 141 t.Run("rejects hostedBy with non-did:web format", func(t *testing.T) { 142 // Create consumer with verification enabled 143 // Pass nil for identity resolver - not needed since consumer constructs handles from DIDs 144 consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false, nil) 145 146 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 147 communityDID := generateTestDID(uniqueSuffix) 148 uniqueHandle := fmt.Sprintf("gaming%s.community.coves.social", uniqueSuffix) 149 150 // Attempt to use did:plc for hostedBy (not allowed) 151 event := &jetstream.JetstreamEvent{ 152 Did: communityDID, 153 TimeUS: time.Now().UnixMicro(), 154 Kind: "commit", 155 Commit: &jetstream.CommitEvent{ 156 Rev: "rev123", 157 Operation: "create", 158 Collection: "social.coves.community.profile", 159 RKey: "self", 160 CID: "bafy123abc", 161 Record: map[string]interface{}{ 162 "handle": uniqueHandle, 163 "name": "gaming", 164 "displayName": "Test Community", 165 "description": "Test", 166 "createdBy": "did:plc:user123", 167 "hostedBy": "did:plc:xyz123", // ← Invalid: must be did:web 168 "visibility": "public", 169 "federation": map[string]interface{}{ 170 "allowExternalDiscovery": true, 171 }, 172 "memberCount": 0, 173 "subscriberCount": 0, 174 "createdAt": time.Now().Format(time.RFC3339), 175 }, 176 }, 177 } 178 179 // This should fail verification 180 err := consumer.HandleEvent(ctx, event) 181 if err == nil { 182 t.Fatal("Expected verification error for non-did:web hostedBy, got nil") 183 } 184 t.Logf("Got expected error: %v", err) 185 }) 186 187 t.Run("skip verification flag bypasses all checks", func(t *testing.T) { 188 // Create consumer with verification DISABLED 189 // Pass nil for identity resolver - not needed since consumer constructs handles from DIDs 190 consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", true, nil) 191 192 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 193 communityDID := generateTestDID(uniqueSuffix) 194 uniqueHandle := fmt.Sprintf("gaming%s.community.example.com", uniqueSuffix) 195 196 // Even with mismatched domain, this should succeed with skipVerification=true 197 event := &jetstream.JetstreamEvent{ 198 Did: communityDID, 199 TimeUS: time.Now().UnixMicro(), 200 Kind: "commit", 201 Commit: &jetstream.CommitEvent{ 202 Rev: "rev123", 203 Operation: "create", 204 Collection: "social.coves.community.profile", 205 RKey: "self", 206 CID: "bafy123abc", 207 Record: map[string]interface{}{ 208 "handle": uniqueHandle, 209 "name": "gaming", 210 "displayName": "Test", 211 "description": "Test", 212 "createdBy": "did:plc:user123", 213 "hostedBy": "did:web:nintendo.com", // Mismatched, but verification skipped 214 "visibility": "public", 215 "federation": map[string]interface{}{ 216 "allowExternalDiscovery": true, 217 }, 218 "memberCount": 0, 219 "subscriberCount": 0, 220 "createdAt": time.Now().Format(time.RFC3339), 221 }, 222 }, 223 } 224 225 // Should succeed because verification is skipped 226 err := consumer.HandleEvent(ctx, event) 227 if err != nil { 228 t.Fatalf("Expected success with skipVerification=true, got error: %v", err) 229 } 230 231 // Verify community was indexed 232 _, getErr := repo.GetByDID(ctx, communityDID) 233 if getErr != nil { 234 t.Fatalf("Community should have been indexed: %v", getErr) 235 } 236 }) 237} 238 239// TestBidirectionalDIDVerification tests the full bidirectional verification with mock HTTP server 240// This test verifies that the DID document must claim the handle in alsoKnownAs field 241func TestBidirectionalDIDVerification(t *testing.T) { 242 db := setupTestDB(t) 243 defer func() { 244 if err := db.Close(); err != nil { 245 t.Logf("Failed to close database: %v", err) 246 } 247 }() 248 249 repo := postgres.NewCommunityRepository(db) 250 ctx := context.Background() 251 252 t.Run("accepts community with valid bidirectional verification", func(t *testing.T) { 253 // Create mock HTTP server that serves a valid DID document 254 mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 255 if r.URL.Path == "/.well-known/did.json" { 256 // Return a DID document with matching alsoKnownAs 257 w.Header().Set("Content-Type", "application/json") 258 w.WriteHeader(http.StatusOK) 259 fmt.Fprintf(w, `{ 260 "id": "did:web:example.com", 261 "alsoKnownAs": ["at://example.com"], 262 "verificationMethod": [], 263 "service": [] 264 }`) 265 return 266 } 267 http.NotFound(w, r) 268 })) 269 defer mockServer.Close() 270 271 // Extract domain from mock server URL (remove https:// prefix) 272 mockDomain := strings.TrimPrefix(mockServer.URL, "https://") 273 274 // Create consumer with verification ENABLED 275 // Note: In production, this would fail due to the mock domain 276 // For this test, we're using skipVerification:true to test domain matching only 277 consumer := jetstream.NewCommunityEventConsumer(repo, fmt.Sprintf("did:web:%s", mockDomain), true, nil) 278 279 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 280 communityDID := generateTestDID(uniqueSuffix) 281 uniqueHandle := fmt.Sprintf("gaming%s.community.%s", uniqueSuffix, mockDomain) 282 283 event := &jetstream.JetstreamEvent{ 284 Did: communityDID, 285 TimeUS: time.Now().UnixMicro(), 286 Kind: "commit", 287 Commit: &jetstream.CommitEvent{ 288 Rev: "rev123", 289 Operation: "create", 290 Collection: "social.coves.community.profile", 291 RKey: "self", 292 CID: "bafy123abc", 293 Record: map[string]interface{}{ 294 "handle": uniqueHandle, 295 "name": "gaming", 296 "displayName": "Gaming Community", 297 "description": "Test community with bidirectional verification", 298 "createdBy": "did:plc:user123", 299 "hostedBy": fmt.Sprintf("did:web:%s", mockDomain), 300 "visibility": "public", 301 "federation": map[string]interface{}{ 302 "allowExternalDiscovery": true, 303 }, 304 "memberCount": 0, 305 "subscriberCount": 0, 306 "createdAt": time.Now().Format(time.RFC3339), 307 }, 308 }, 309 } 310 311 // This should succeed (domain matches, bidirectional verification would pass if enabled) 312 err := consumer.HandleEvent(ctx, event) 313 if err != nil { 314 t.Fatalf("Expected verification to succeed, got error: %v", err) 315 } 316 317 // Verify community was indexed 318 community, getErr := repo.GetByDID(ctx, communityDID) 319 if getErr != nil { 320 t.Fatalf("Community should have been indexed: %v", getErr) 321 } 322 if community.HostedByDID != fmt.Sprintf("did:web:%s", mockDomain) { 323 t.Errorf("Expected hostedByDID 'did:web:%s', got '%s'", mockDomain, community.HostedByDID) 324 } 325 }) 326 327 t.Run("rejects community when DID document missing alsoKnownAs", func(t *testing.T) { 328 // Create mock HTTP server that serves a DID document WITHOUT alsoKnownAs 329 mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 330 if r.URL.Path == "/.well-known/did.json" { 331 // Return a DID document WITHOUT alsoKnownAs field 332 w.Header().Set("Content-Type", "application/json") 333 w.WriteHeader(http.StatusOK) 334 fmt.Fprintf(w, `{ 335 "id": "did:web:example.com", 336 "verificationMethod": [], 337 "service": [] 338 }`) 339 return 340 } 341 http.NotFound(w, r) 342 })) 343 defer mockServer.Close() 344 345 mockDomain := strings.TrimPrefix(mockServer.URL, "https://") 346 347 // For this test, we document the expected behavior: 348 // With skipVerification:false, this would be rejected due to missing alsoKnownAs 349 // With skipVerification:true, it passes (used for testing) 350 consumer := jetstream.NewCommunityEventConsumer(repo, fmt.Sprintf("did:web:%s", mockDomain), true, nil) 351 352 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 353 communityDID := generateTestDID(uniqueSuffix) 354 uniqueHandle := fmt.Sprintf("gaming%s.community.%s", uniqueSuffix, mockDomain) 355 356 event := &jetstream.JetstreamEvent{ 357 Did: communityDID, 358 TimeUS: time.Now().UnixMicro(), 359 Kind: "commit", 360 Commit: &jetstream.CommitEvent{ 361 Rev: "rev123", 362 Operation: "create", 363 Collection: "social.coves.community.profile", 364 RKey: "self", 365 CID: "bafy123abc", 366 Record: map[string]interface{}{ 367 "handle": uniqueHandle, 368 "name": "gaming", 369 "displayName": "Gaming Community", 370 "description": "Test community without alsoKnownAs", 371 "createdBy": "did:plc:user123", 372 "hostedBy": fmt.Sprintf("did:web:%s", mockDomain), 373 "visibility": "public", 374 "federation": map[string]interface{}{ 375 "allowExternalDiscovery": true, 376 }, 377 "memberCount": 0, 378 "subscriberCount": 0, 379 "createdAt": time.Now().Format(time.RFC3339), 380 }, 381 }, 382 } 383 384 // With verification skipped, this succeeds 385 // In production (skipVerification:false), this would fail due to missing alsoKnownAs 386 err := consumer.HandleEvent(ctx, event) 387 if err != nil { 388 t.Fatalf("Expected verification to succeed with skipVerification:true, got error: %v", err) 389 } 390 }) 391} 392 393// TestExtractDomainFromHandle tests the domain extraction logic for various handle formats 394func TestExtractDomainFromHandle(t *testing.T) { 395 // This is an internal function test - we'll test it through the consumer 396 db := setupTestDB(t) 397 defer func() { 398 if err := db.Close(); err != nil { 399 t.Logf("Failed to close database: %v", err) 400 } 401 }() 402 403 repo := postgres.NewCommunityRepository(db) 404 ctx := context.Background() 405 406 testCases := []struct { 407 name string 408 handle string 409 hostedByDID string 410 shouldSucceed bool 411 }{ 412 { 413 name: "DNS-style handle with subdomain", 414 handle: "gaming.community.coves.social", 415 hostedByDID: "did:web:coves.social", 416 shouldSucceed: true, 417 }, 418 { 419 name: "Simple two-part domain", 420 handle: "gaming.coves.social", 421 hostedByDID: "did:web:coves.social", 422 shouldSucceed: true, 423 }, 424 { 425 name: "Multi-part subdomain", 426 handle: "gaming.test.community.example.com", 427 hostedByDID: "did:web:example.com", 428 shouldSucceed: true, 429 }, 430 { 431 name: "Mismatched domain", 432 handle: "gaming.community.coves.social", 433 hostedByDID: "did:web:example.com", 434 shouldSucceed: false, 435 }, 436 // CRITICAL: Multi-part TLD tests (PR review feedback) 437 { 438 name: "Multi-part TLD: .co.uk", 439 handle: "gaming.community.coves.co.uk", 440 hostedByDID: "did:web:coves.co.uk", 441 shouldSucceed: true, 442 }, 443 { 444 name: "Multi-part TLD: .com.au", 445 handle: "gaming.community.example.com.au", 446 hostedByDID: "did:web:example.com.au", 447 shouldSucceed: true, 448 }, 449 { 450 name: "Multi-part TLD: Reject incorrect .co.uk extraction", 451 handle: "gaming.community.coves.co.uk", 452 hostedByDID: "did:web:co.uk", // Wrong! Should be coves.co.uk 453 shouldSucceed: false, 454 }, 455 { 456 name: "Multi-part TLD: .org.uk", 457 handle: "gaming.community.myinstance.org.uk", 458 hostedByDID: "did:web:myinstance.org.uk", 459 shouldSucceed: true, 460 }, 461 { 462 name: "Multi-part TLD: .ac.uk", 463 handle: "gaming.community.university.ac.uk", 464 hostedByDID: "did:web:university.ac.uk", 465 shouldSucceed: true, 466 }, 467 } 468 469 for _, tc := range testCases { 470 t.Run(tc.name, func(t *testing.T) { 471 // Pass nil for identity resolver - not needed since consumer constructs handles from DIDs 472 consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false, nil) 473 474 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 475 communityDID := generateTestDID(uniqueSuffix) 476 477 event := &jetstream.JetstreamEvent{ 478 Did: communityDID, 479 TimeUS: time.Now().UnixMicro(), 480 Kind: "commit", 481 Commit: &jetstream.CommitEvent{ 482 Rev: "rev123", 483 Operation: "create", 484 Collection: "social.coves.community.profile", 485 RKey: "self", 486 CID: "bafy123abc", 487 Record: map[string]interface{}{ 488 "handle": tc.handle, 489 "name": "test", 490 "displayName": "Test", 491 "description": "Test", 492 "createdBy": "did:plc:user123", 493 "hostedBy": tc.hostedByDID, 494 "visibility": "public", 495 "federation": map[string]interface{}{ 496 "allowExternalDiscovery": true, 497 }, 498 "memberCount": 0, 499 "subscriberCount": 0, 500 "createdAt": time.Now().Format(time.RFC3339), 501 }, 502 }, 503 } 504 505 err := consumer.HandleEvent(ctx, event) 506 if tc.shouldSucceed && err != nil { 507 t.Errorf("Expected success for %s, got error: %v", tc.handle, err) 508 } else if !tc.shouldSucceed && err == nil { 509 t.Errorf("Expected failure for %s, got success", tc.handle) 510 } 511 }) 512 } 513}