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}