A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/atproto/identity" 5 "context" 6 "fmt" 7 "os" 8 "testing" 9 "time" 10) 11 12// uniqueID generates a unique identifier for test isolation 13func uniqueID() string { 14 return fmt.Sprintf("test-%d", time.Now().UnixNano()) 15} 16 17// TestIdentityCache tests the PostgreSQL identity cache operations 18func TestIdentityCache(t *testing.T) { 19 db := setupTestDB(t) 20 defer func() { 21 if err := db.Close(); err != nil { 22 t.Logf("Failed to close database: %v", err) 23 } 24 }() 25 26 cache := identity.NewPostgresCache(db, 5*time.Minute) 27 ctx := context.Background() 28 29 // Generate unique test prefix for parallel safety 30 testID := fmt.Sprintf("test-%d", time.Now().UnixNano()) 31 32 t.Run("Cache Miss on Empty Cache", func(t *testing.T) { 33 _, err := cache.Get(ctx, testID+"-nonexistent.test") 34 if err == nil { 35 t.Error("Expected cache miss error, got nil") 36 } 37 }) 38 39 t.Run("Set and Get Identity by Handle", func(t *testing.T) { 40 ident := &identity.Identity{ 41 DID: "did:plc:" + testID + "-test123abc", 42 Handle: testID + "-alice.test", 43 PDSURL: "https://pds.alice.test", 44 ResolvedAt: time.Now().UTC(), 45 Method: identity.MethodHTTPS, 46 } 47 48 // Set identity in cache 49 if err := cache.Set(ctx, ident); err != nil { 50 t.Fatalf("Failed to cache identity: %v", err) 51 } 52 53 // Get by handle 54 cached, err := cache.Get(ctx, ident.Handle) 55 if err != nil { 56 t.Fatalf("Failed to get cached identity by handle: %v", err) 57 } 58 59 if cached.DID != ident.DID { 60 t.Errorf("Expected DID %s, got %s", ident.DID, cached.DID) 61 } 62 if cached.Handle != ident.Handle { 63 t.Errorf("Expected handle %s, got %s", ident.Handle, cached.Handle) 64 } 65 if cached.PDSURL != ident.PDSURL { 66 t.Errorf("Expected PDS URL %s, got %s", ident.PDSURL, cached.PDSURL) 67 } 68 }) 69 70 t.Run("Get Identity by DID", func(t *testing.T) { 71 // Should be able to retrieve by DID as well (bidirectional cache) 72 expectedDID := "did:plc:" + testID + "-test123abc" 73 expectedHandle := testID + "-alice.test" 74 75 cached, err := cache.Get(ctx, expectedDID) 76 if err != nil { 77 t.Fatalf("Failed to get cached identity by DID: %v", err) 78 } 79 80 if cached.Handle != expectedHandle { 81 t.Errorf("Expected handle %s, got %s", expectedHandle, cached.Handle) 82 } 83 }) 84 85 t.Run("Update Existing Cache Entry", func(t *testing.T) { 86 // Update with new PDS URL 87 updated := &identity.Identity{ 88 DID: "did:plc:test123abc", 89 Handle: "alice.test", 90 PDSURL: "https://new-pds.alice.test", 91 ResolvedAt: time.Now(), 92 Method: identity.MethodHTTPS, 93 } 94 95 if err := cache.Set(ctx, updated); err != nil { 96 t.Fatalf("Failed to update cached identity: %v", err) 97 } 98 99 cached, err := cache.Get(ctx, "alice.test") 100 if err != nil { 101 t.Fatalf("Failed to get updated identity: %v", err) 102 } 103 104 if cached.PDSURL != "https://new-pds.alice.test" { 105 t.Errorf("Expected updated PDS URL, got %s", cached.PDSURL) 106 } 107 }) 108 109 t.Run("Delete Cache Entry", func(t *testing.T) { 110 if err := cache.Delete(ctx, "alice.test"); err != nil { 111 t.Fatalf("Failed to delete cache entry: %v", err) 112 } 113 114 // Should now be a cache miss 115 _, err := cache.Get(ctx, "alice.test") 116 if err == nil { 117 t.Error("Expected cache miss after deletion, got nil error") 118 } 119 }) 120 121 t.Run("Purge Removes Both Handle and DID Entries", func(t *testing.T) { 122 ident := &identity.Identity{ 123 DID: "did:plc:purgetest", 124 Handle: "purge.test", 125 PDSURL: "https://pds.purge.test", 126 ResolvedAt: time.Now(), 127 Method: identity.MethodDNS, 128 } 129 130 if err := cache.Set(ctx, ident); err != nil { 131 t.Fatalf("Failed to cache identity: %v", err) 132 } 133 134 // Verify both entries exist 135 if _, err := cache.Get(ctx, "purge.test"); err != nil { 136 t.Errorf("Handle entry should exist: %v", err) 137 } 138 if _, err := cache.Get(ctx, "did:plc:purgetest"); err != nil { 139 t.Errorf("DID entry should exist: %v", err) 140 } 141 142 // Purge by handle 143 if err := cache.Purge(ctx, "purge.test"); err != nil { 144 t.Fatalf("Failed to purge: %v", err) 145 } 146 147 // Both should be gone 148 if _, err := cache.Get(ctx, "purge.test"); err == nil { 149 t.Error("Handle entry should be purged") 150 } 151 if _, err := cache.Get(ctx, "did:plc:purgetest"); err == nil { 152 t.Error("DID entry should be purged") 153 } 154 }) 155 156 t.Run("Handle Normalization - Case Insensitive", func(t *testing.T) { 157 ident := &identity.Identity{ 158 DID: "did:plc:casetest", 159 Handle: "Alice.Test", 160 PDSURL: "https://pds.alice.test", 161 ResolvedAt: time.Now(), 162 Method: identity.MethodHTTPS, 163 } 164 165 if err := cache.Set(ctx, ident); err != nil { 166 t.Fatalf("Failed to cache identity: %v", err) 167 } 168 169 // Should be retrievable with different casing 170 cached, err := cache.Get(ctx, "ALICE.TEST") 171 if err != nil { 172 t.Fatalf("Failed to get identity with different casing: %v", err) 173 } 174 175 if cached.DID != "did:plc:casetest" { 176 t.Errorf("Expected DID did:plc:casetest, got %s", cached.DID) 177 } 178 179 // Cleanup 180 if delErr := cache.Delete(ctx, "alice.test"); delErr != nil { 181 t.Logf("Failed to delete cache entry: %v", delErr) 182 } 183 }) 184 185 t.Run("DID is Case Sensitive", func(t *testing.T) { 186 ident := &identity.Identity{ 187 DID: "did:plc:CaseSensitive", 188 Handle: "sensitive.test", 189 PDSURL: "https://pds.test", 190 ResolvedAt: time.Now(), 191 Method: identity.MethodHTTPS, 192 } 193 194 if err := cache.Set(ctx, ident); err != nil { 195 t.Fatalf("Failed to cache identity: %v", err) 196 } 197 198 // Should retrieve with exact case 199 if _, err := cache.Get(ctx, "did:plc:CaseSensitive"); err != nil { 200 t.Errorf("Should retrieve DID with exact case: %v", err) 201 } 202 203 // Different case should miss (DIDs are case-sensitive) 204 if _, err := cache.Get(ctx, "did:plc:casesensitive"); err == nil { 205 t.Error("Should NOT retrieve DID with different case") 206 } 207 208 // Cleanup 209 if delErr := cache.Delete(ctx, "did:plc:CaseSensitive"); delErr != nil { 210 t.Logf("Failed to delete cache entry: %v", delErr) 211 } 212 }) 213} 214 215// TestIdentityCacheTTL tests that expired cache entries are not returned 216func TestIdentityCacheTTL(t *testing.T) { 217 db := setupTestDB(t) 218 defer func() { 219 if err := db.Close(); err != nil { 220 t.Logf("Failed to close database: %v", err) 221 } 222 }() 223 224 // Create cache with very short TTL (reduced from 1s to 100ms for faster, less flaky tests) 225 ttl := 100 * time.Millisecond 226 cache := identity.NewPostgresCache(db, ttl) 227 ctx := context.Background() 228 229 // Use unique ID for test isolation 230 testID := uniqueID() 231 232 ident := &identity.Identity{ 233 DID: "did:plc:" + testID, 234 Handle: testID + ".ttl.test", 235 PDSURL: "https://pds.ttl.test", 236 ResolvedAt: time.Now().UTC(), 237 Method: identity.MethodHTTPS, 238 } 239 240 if err := cache.Set(ctx, ident); err != nil { 241 t.Fatalf("Failed to cache identity: %v", err) 242 } 243 244 // Should be retrievable immediately 245 if _, err := cache.Get(ctx, ident.Handle); err != nil { 246 t.Errorf("Should retrieve fresh cache entry: %v", err) 247 } 248 249 // Wait for TTL to expire (1.5x TTL for safety margin on slow systems) 250 waitTime := time.Duration(float64(ttl) * 1.5) 251 t.Logf("Waiting %s for cache entry to expire (TTL=%s)...", waitTime, ttl) 252 time.Sleep(waitTime) 253 254 // Should now be a cache miss 255 _, err := cache.Get(ctx, ident.Handle) 256 if err == nil { 257 t.Error("Expected cache miss after TTL expiration, got nil error") 258 } 259} 260 261// TestIdentityResolverWithCache tests the caching resolver behavior 262func TestIdentityResolverWithCache(t *testing.T) { 263 db := setupTestDB(t) 264 defer func() { 265 if err := db.Close(); err != nil { 266 t.Logf("Failed to close database: %v", err) 267 } 268 }() 269 270 cache := identity.NewPostgresCache(db, 5*time.Minute) 271 272 // Clean slate 273 if _, err := db.Exec("TRUNCATE identity_cache"); err != nil { 274 t.Logf("Warning: failed to truncate identity_cache: %v", err) 275 } 276 277 // Create resolver with caching 278 resolver := identity.NewResolver(db, identity.Config{ 279 PLCURL: "https://plc.directory", 280 CacheTTL: 5 * time.Minute, 281 }) 282 283 ctx := context.Background() 284 285 t.Run("Resolve Invalid Identifier", func(t *testing.T) { 286 _, err := resolver.Resolve(ctx, "") 287 if err == nil { 288 t.Error("Expected error for empty identifier") 289 } 290 291 _, err = resolver.Resolve(ctx, "invalid format") 292 if err == nil { 293 t.Error("Expected error for invalid identifier format") 294 } 295 }) 296 297 t.Run("ResolveHandle Returns DID and PDS URL", func(t *testing.T) { 298 // Pre-populate cache with known identity 299 ident := &identity.Identity{ 300 DID: "did:plc:resolvetest", 301 Handle: "resolve.test", 302 PDSURL: "https://pds.resolve.test", 303 ResolvedAt: time.Now(), 304 Method: identity.MethodDNS, 305 } 306 307 if err := cache.Set(ctx, ident); err != nil { 308 t.Fatalf("Failed to pre-populate cache: %v", err) 309 } 310 311 did, pdsURL, err := resolver.ResolveHandle(ctx, "resolve.test") 312 if err != nil { 313 t.Fatalf("Failed to resolve handle: %v", err) 314 } 315 316 if did != "did:plc:resolvetest" { 317 t.Errorf("Expected DID did:plc:resolvetest, got %s", did) 318 } 319 if pdsURL != "https://pds.resolve.test" { 320 t.Errorf("Expected PDS URL https://pds.resolve.test, got %s", pdsURL) 321 } 322 }) 323 324 t.Run("Purge Removes from Cache", func(t *testing.T) { 325 // Pre-populate cache 326 ident := &identity.Identity{ 327 DID: "did:plc:purge123", 328 Handle: "purgetest.test", 329 PDSURL: "https://pds.test", 330 ResolvedAt: time.Now(), 331 Method: identity.MethodHTTPS, 332 } 333 334 if err := cache.Set(ctx, ident); err != nil { 335 t.Fatalf("Failed to cache identity: %v", err) 336 } 337 338 // Verify it's cached 339 if _, err := cache.Get(ctx, "purgetest.test"); err != nil { 340 t.Fatalf("Identity should be cached: %v", err) 341 } 342 343 // Purge via resolver 344 if err := resolver.Purge(ctx, "purgetest.test"); err != nil { 345 t.Fatalf("Failed to purge: %v", err) 346 } 347 348 // Should be gone from cache 349 if _, err := cache.Get(ctx, "purgetest.test"); err == nil { 350 t.Error("Identity should be purged from cache") 351 } 352 }) 353} 354 355// TestIdentityResolverRealHandles tests resolution with real atProto handles 356// This is an optional integration test that requires network access 357func TestIdentityResolverRealHandles(t *testing.T) { 358 if testing.Short() { 359 t.Skip("Skipping real handle resolution test in short mode") 360 } 361 362 // Skip if environment variable is not set (opt-in for real network tests) 363 if os.Getenv("TEST_REAL_HANDLES") != "1" { 364 t.Skip("Skipping real handle resolution - set TEST_REAL_HANDLES=1 to enable") 365 } 366 367 db := setupTestDB(t) 368 defer func() { 369 if err := db.Close(); err != nil { 370 t.Logf("Failed to close database: %v", err) 371 } 372 }() 373 374 resolver := identity.NewResolver(db, identity.Config{ 375 PLCURL: "https://plc.directory", 376 CacheTTL: 10 * time.Minute, 377 }) 378 379 ctx := context.Background() 380 381 testCases := []struct { 382 name string 383 handle string 384 expectedMethod identity.ResolutionMethod 385 expectError bool 386 }{ 387 { 388 name: "Resolve bsky.app (well-known handle)", 389 handle: "bsky.app", 390 expectError: false, 391 expectedMethod: identity.MethodHTTPS, 392 }, 393 { 394 name: "Resolve nonexistent handle", 395 handle: "this-handle-definitely-does-not-exist-12345.bsky.social", 396 expectError: true, 397 }, 398 } 399 400 for _, tc := range testCases { 401 t.Run(tc.name, func(t *testing.T) { 402 ident, err := resolver.Resolve(ctx, tc.handle) 403 404 if tc.expectError { 405 if err == nil { 406 t.Error("Expected error for nonexistent handle") 407 } 408 return 409 } 410 411 if err != nil { 412 t.Fatalf("Failed to resolve handle %s: %v", tc.handle, err) 413 } 414 415 if ident.Handle != tc.handle { 416 t.Errorf("Expected handle %s, got %s", tc.handle, ident.Handle) 417 } 418 419 if ident.DID == "" { 420 t.Error("Expected non-empty DID") 421 } 422 423 if ident.PDSURL == "" { 424 t.Error("Expected non-empty PDS URL") 425 } 426 427 t.Logf("✅ Resolved %s → %s (PDS: %s, Method: %s)", 428 ident.Handle, ident.DID, ident.PDSURL, ident.Method) 429 430 // Second resolution should hit cache 431 ident2, err := resolver.Resolve(ctx, tc.handle) 432 if err != nil { 433 t.Fatalf("Failed second resolution: %v", err) 434 } 435 436 if ident2.Method != identity.MethodCache { 437 t.Errorf("Second resolution should be from cache, got method: %s", ident2.Method) 438 } 439 440 t.Logf("✅ Second resolution from cache: %s (Method: %s)", tc.handle, ident2.Method) 441 }) 442 } 443} 444 445// TestResolveDID tests DID document resolution 446func TestResolveDID(t *testing.T) { 447 if testing.Short() { 448 t.Skip("Skipping DID resolution test in short mode") 449 } 450 451 if os.Getenv("TEST_REAL_HANDLES") != "1" { 452 t.Skip("Skipping DID resolution - set TEST_REAL_HANDLES=1 to enable") 453 } 454 455 db := setupTestDB(t) 456 defer func() { 457 if err := db.Close(); err != nil { 458 t.Logf("Failed to close database: %v", err) 459 } 460 }() 461 462 resolver := identity.NewResolver(db, identity.Config{ 463 PLCURL: "https://plc.directory", 464 CacheTTL: 10 * time.Minute, 465 }) 466 467 ctx := context.Background() 468 469 t.Run("Resolve Real DID Document", func(t *testing.T) { 470 // First resolve a handle to get a real DID 471 ident, err := resolver.Resolve(ctx, "bsky.app") 472 if err != nil { 473 t.Skipf("Failed to resolve handle for DID test: %v", err) 474 } 475 476 // Now resolve the DID document 477 doc, err := resolver.ResolveDID(ctx, ident.DID) 478 if err != nil { 479 t.Fatalf("Failed to resolve DID document: %v", err) 480 } 481 482 if doc.DID != ident.DID { 483 t.Errorf("Expected DID %s, got %s", ident.DID, doc.DID) 484 } 485 486 // Should have at least PDS service 487 if len(doc.Service) == 0 { 488 t.Error("Expected at least one service in DID document") 489 } 490 491 // Find PDS service 492 foundPDS := false 493 for _, svc := range doc.Service { 494 if svc.Type == "AtprotoPersonalDataServer" { 495 foundPDS = true 496 if svc.ServiceEndpoint == "" { 497 t.Error("PDS service endpoint should not be empty") 498 } 499 t.Logf("✅ PDS Service: %s", svc.ServiceEndpoint) 500 } 501 } 502 503 if !foundPDS { 504 t.Error("Expected to find AtprotoPersonalDataServer service in DID document") 505 } 506 }) 507 508 t.Run("Resolve Invalid DID", func(t *testing.T) { 509 _, err := resolver.ResolveDID(ctx, "not-a-did") 510 if err == nil { 511 t.Error("Expected error for invalid DID format") 512 } 513 }) 514}