A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/core/communities" 5 "Coves/internal/db/postgres" 6 "context" 7 "fmt" 8 "strings" 9 "testing" 10 "time" 11) 12 13// TestCommunityRepository_PasswordEncryption verifies P0 fix: 14// Password must be encrypted (not hashed) so we can recover it for session renewal 15func TestCommunityRepository_PasswordEncryption(t *testing.T) { 16 db := setupTestDB(t) 17 defer func() { 18 if err := db.Close(); err != nil { 19 t.Logf("Failed to close database: %v", err) 20 } 21 }() 22 23 repo := postgres.NewCommunityRepository(db) 24 ctx := context.Background() 25 26 t.Run("encrypts and decrypts password correctly", func(t *testing.T) { 27 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 28 testPassword := "test-password-12345678901234567890" 29 30 community := &communities.Community{ 31 DID: generateTestDID(uniqueSuffix), 32 Handle: fmt.Sprintf("test-encryption-%s.community.test.local", uniqueSuffix), 33 Name: "test-encryption", 34 DisplayName: "Test Encryption", 35 Description: "Testing password encryption", 36 OwnerDID: "did:web:test.local", 37 CreatedByDID: "did:plc:testuser", 38 HostedByDID: "did:web:test.local", 39 PDSEmail: "test@test.local", 40 PDSPassword: testPassword, // Cleartext password 41 PDSAccessToken: "test-access-token", 42 PDSRefreshToken: "test-refresh-token", 43 PDSURL: "http://localhost:3001", 44 Visibility: "public", 45 AllowExternalDiscovery: true, 46 CreatedAt: time.Now(), 47 UpdatedAt: time.Now(), 48 } 49 50 // Create community with password 51 created, err := repo.Create(ctx, community) 52 if err != nil { 53 t.Fatalf("Failed to create community: %v", err) 54 } 55 56 // CRITICAL: Query database directly to verify password is ENCRYPTED at rest 57 var encryptedPassword []byte 58 query := ` 59 SELECT pds_password_encrypted 60 FROM communities 61 WHERE did = $1 62 ` 63 if err := db.QueryRowContext(ctx, query, created.DID).Scan(&encryptedPassword); err != nil { 64 t.Fatalf("Failed to query encrypted password: %v", err) 65 } 66 67 // Verify password is NOT stored as plaintext 68 if string(encryptedPassword) == testPassword { 69 t.Error("CRITICAL: Password is stored as plaintext in database! Must be encrypted.") 70 } 71 72 // Verify password is NOT stored as bcrypt hash (would start with $2a$, $2b$, or $2y$) 73 if strings.HasPrefix(string(encryptedPassword), "$2") { 74 t.Error("Password appears to be bcrypt hashed instead of pgcrypto encrypted!") 75 } 76 77 // Verify encrypted data is not empty 78 if len(encryptedPassword) == 0 { 79 t.Error("Expected encrypted password to have data") 80 } 81 82 t.Logf("✅ Password is encrypted in database (not plaintext or bcrypt)") 83 84 // Retrieve community - password should be decrypted by repository 85 retrieved, err := repo.GetByDID(ctx, created.DID) 86 if err != nil { 87 t.Fatalf("Failed to retrieve community: %v", err) 88 } 89 90 // Verify password roundtrip (encrypted → decrypted) 91 if retrieved.PDSPassword != testPassword { 92 t.Errorf("Password roundtrip failed: expected %q, got %q", testPassword, retrieved.PDSPassword) 93 } 94 95 t.Logf("✅ Password decrypted correctly on retrieval: %d chars", len(retrieved.PDSPassword)) 96 }) 97 98 t.Run("handles empty password gracefully", func(t *testing.T) { 99 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()+1) 100 101 community := &communities.Community{ 102 DID: generateTestDID(uniqueSuffix), 103 Handle: fmt.Sprintf("test-empty-pass-%s.community.test.local", uniqueSuffix), 104 Name: "test-empty-pass", 105 DisplayName: "Test Empty Password", 106 Description: "Testing empty password handling", 107 OwnerDID: "did:web:test.local", 108 CreatedByDID: "did:plc:testuser", 109 HostedByDID: "did:web:test.local", 110 PDSEmail: "test2@test.local", 111 PDSPassword: "", // Empty password 112 PDSAccessToken: "test-access-token", 113 PDSRefreshToken: "test-refresh-token", 114 PDSURL: "http://localhost:3001", 115 Visibility: "public", 116 AllowExternalDiscovery: true, 117 CreatedAt: time.Now(), 118 UpdatedAt: time.Now(), 119 } 120 121 created, err := repo.Create(ctx, community) 122 if err != nil { 123 t.Fatalf("Failed to create community with empty password: %v", err) 124 } 125 126 retrieved, err := repo.GetByDID(ctx, created.DID) 127 if err != nil { 128 t.Fatalf("Failed to retrieve community: %v", err) 129 } 130 131 if retrieved.PDSPassword != "" { 132 t.Errorf("Expected empty password, got: %q", retrieved.PDSPassword) 133 } 134 }) 135} 136 137// TestCommunityService_NameValidation verifies P1 fix: 138// Community names must respect DNS label limits (63 chars max) 139func TestCommunityService_NameValidation(t *testing.T) { 140 db := setupTestDB(t) 141 defer func() { 142 if err := db.Close(); err != nil { 143 t.Logf("Failed to close database: %v", err) 144 } 145 }() 146 147 repo := postgres.NewCommunityRepository(db) 148 provisioner := communities.NewPDSAccountProvisioner("test.local", "http://localhost:3001") 149 service := communities.NewCommunityService( 150 repo, 151 "http://localhost:3001", // pdsURL 152 "did:web:test.local", // instanceDID 153 "test.local", // instanceDomain 154 provisioner, 155 ) 156 ctx := context.Background() 157 158 t.Run("rejects empty name", func(t *testing.T) { 159 req := communities.CreateCommunityRequest{ 160 Name: "", // Empty! 161 DisplayName: "Empty Name Test", 162 Description: "This should fail", 163 Visibility: "public", 164 CreatedByDID: "did:plc:testuser", 165 HostedByDID: "did:web:test.local", 166 AllowExternalDiscovery: true, 167 } 168 169 _, err := service.CreateCommunity(ctx, req) 170 if err == nil { 171 t.Error("Expected error for empty name, got nil") 172 } 173 174 if !strings.Contains(err.Error(), "name") { 175 t.Errorf("Expected 'name' error, got: %v", err) 176 } 177 }) 178 179 t.Run("rejects 64-char name (exceeds DNS limit)", func(t *testing.T) { 180 // DNS label limit is 63 characters 181 longName := strings.Repeat("a", 64) 182 183 req := communities.CreateCommunityRequest{ 184 Name: longName, 185 DisplayName: "Long Name Test", 186 Description: "This should fail - name too long for DNS", 187 Visibility: "public", 188 CreatedByDID: "did:plc:testuser", 189 HostedByDID: "did:web:test.local", 190 AllowExternalDiscovery: true, 191 } 192 193 _, err := service.CreateCommunity(ctx, req) 194 if err == nil { 195 t.Error("Expected error for 64-char name, got nil") 196 } 197 198 if !strings.Contains(err.Error(), "63") || !strings.Contains(err.Error(), "name") { 199 t.Errorf("Expected '63 characters' name error, got: %v", err) 200 } 201 202 t.Logf("✅ Correctly rejected 64-char name: %v", err) 203 }) 204 205 t.Run("accepts 63-char name (exactly at DNS limit)", func(t *testing.T) { 206 // This should be accepted - exactly 63 chars 207 maxName := strings.Repeat("a", 63) 208 209 req := communities.CreateCommunityRequest{ 210 Name: maxName, 211 DisplayName: "Max Name Test", 212 Description: "This should succeed - exactly at DNS limit", 213 Visibility: "public", 214 CreatedByDID: "did:plc:testuser", 215 HostedByDID: "did:web:test.local", 216 AllowExternalDiscovery: true, 217 } 218 219 // This will fail at PDS provisioning (no mock PDS), but should pass validation 220 _, err := service.CreateCommunity(ctx, req) 221 222 // We expect PDS provisioning to fail, but NOT validation 223 if err != nil && strings.Contains(err.Error(), "63 characters") { 224 t.Errorf("Name validation should pass for 63-char name, got: %v", err) 225 } 226 227 t.Logf("✅ 63-char name passed validation (may fail at PDS provisioning)") 228 }) 229 230 t.Run("rejects special characters in name", func(t *testing.T) { 231 testCases := []struct { 232 name string 233 errorDesc string 234 }{ 235 {"test!community", "exclamation mark"}, 236 {"test@space", "at symbol"}, 237 {"test community", "space"}, 238 {"test.community", "period/dot"}, 239 {"test_community", "underscore"}, 240 {"test#tag", "hash"}, 241 {"-testcommunity", "leading hyphen"}, 242 {"testcommunity-", "trailing hyphen"}, 243 } 244 245 for _, tc := range testCases { 246 t.Run(tc.errorDesc, func(t *testing.T) { 247 req := communities.CreateCommunityRequest{ 248 Name: tc.name, 249 DisplayName: "Special Char Test", 250 Description: "Testing special character rejection", 251 Visibility: "public", 252 CreatedByDID: "did:plc:testuser", 253 HostedByDID: "did:web:test.local", 254 AllowExternalDiscovery: true, 255 } 256 257 _, err := service.CreateCommunity(ctx, req) 258 if err == nil { 259 t.Errorf("Expected error for name with %s: %q", tc.errorDesc, tc.name) 260 } 261 262 if !strings.Contains(err.Error(), "name") { 263 t.Errorf("Expected 'name' error for %q, got: %v", tc.name, err) 264 } 265 }) 266 } 267 }) 268 269 t.Run("accepts valid names", func(t *testing.T) { 270 validNames := []string{ 271 "gaming", 272 "tech-news", 273 "Web3Dev", 274 "community123", 275 "a", // Single character is valid 276 "ab", // Two characters is valid 277 } 278 279 for _, name := range validNames { 280 t.Run(name, func(t *testing.T) { 281 req := communities.CreateCommunityRequest{ 282 Name: name, 283 DisplayName: "Valid Name Test", 284 Description: "Testing valid name acceptance", 285 Visibility: "public", 286 CreatedByDID: "did:plc:testuser", 287 HostedByDID: "did:web:test.local", 288 AllowExternalDiscovery: true, 289 } 290 291 // This will fail at PDS provisioning (no mock PDS), but should pass validation 292 _, err := service.CreateCommunity(ctx, req) 293 294 // We expect PDS provisioning to fail, but NOT name validation 295 if err != nil && strings.Contains(strings.ToLower(err.Error()), "name") && strings.Contains(err.Error(), "alphanumeric") { 296 t.Errorf("Name validation should pass for %q, got: %v", name, err) 297 } 298 }) 299 } 300 }) 301} 302 303// TestPasswordSecurity verifies password generation security properties 304// Critical for P0: Passwords must be unpredictable and have sufficient entropy 305func TestPasswordSecurity(t *testing.T) { 306 db := setupTestDB(t) 307 defer func() { 308 if err := db.Close(); err != nil { 309 t.Logf("Failed to close database: %v", err) 310 } 311 }() 312 313 repo := postgres.NewCommunityRepository(db) 314 ctx := context.Background() 315 316 t.Run("generates unique passwords", func(t *testing.T) { 317 // Create 100 communities and verify each gets a unique password 318 // We test this by storing passwords in the DB (encrypted) and verifying uniqueness 319 passwords := make(map[string]bool) 320 const numCommunities = 100 321 322 // Use a unique base timestamp for this test run to avoid collisions 323 baseTimestamp := time.Now().UnixNano() 324 325 for i := 0; i < numCommunities; i++ { 326 uniqueSuffix := fmt.Sprintf("%d-%d", baseTimestamp, i) 327 328 // Generate a unique password for this test (simulating what provisioner does) 329 // In production, provisioner generates the password, but we can't intercept it 330 // So we generate our own unique passwords and verify they're stored uniquely 331 testPassword := fmt.Sprintf("unique-password-%s", uniqueSuffix) 332 333 community := &communities.Community{ 334 DID: generateTestDID(uniqueSuffix), 335 Handle: fmt.Sprintf("pwd-unique-%s.community.test.local", uniqueSuffix), 336 Name: fmt.Sprintf("pwd-unique-%s", uniqueSuffix), 337 DisplayName: fmt.Sprintf("Password Unique Test %d", i), 338 Description: "Testing password uniqueness", 339 OwnerDID: "did:web:test.local", 340 CreatedByDID: "did:plc:testuser", 341 HostedByDID: "did:web:test.local", 342 PDSEmail: fmt.Sprintf("pwd-unique-%s@test.local", uniqueSuffix), 343 PDSPassword: testPassword, 344 PDSAccessToken: fmt.Sprintf("access-token-%s", uniqueSuffix), 345 PDSRefreshToken: fmt.Sprintf("refresh-token-%s", uniqueSuffix), 346 PDSURL: "http://localhost:3001", 347 Visibility: "public", 348 AllowExternalDiscovery: true, 349 CreatedAt: time.Now(), 350 UpdatedAt: time.Now(), 351 } 352 353 created, err := repo.Create(ctx, community) 354 if err != nil { 355 t.Fatalf("Failed to create community %d: %v", i, err) 356 } 357 358 // Retrieve and verify password 359 retrieved, err := repo.GetByDID(ctx, created.DID) 360 if err != nil { 361 t.Fatalf("Failed to retrieve community %d: %v", i, err) 362 } 363 364 // Verify password was decrypted correctly 365 if retrieved.PDSPassword != testPassword { 366 t.Errorf("Community %d: password mismatch after encryption/decryption", i) 367 } 368 369 // Track password uniqueness 370 if passwords[retrieved.PDSPassword] { 371 t.Errorf("Community %d: duplicate password detected: %s", i, retrieved.PDSPassword) 372 } 373 passwords[retrieved.PDSPassword] = true 374 } 375 376 // Verify all passwords are unique 377 if len(passwords) != numCommunities { 378 t.Errorf("Expected %d unique passwords, got %d", numCommunities, len(passwords)) 379 } 380 381 t.Logf("✅ All %d communities have unique passwords", numCommunities) 382 }) 383 384 t.Run("password has sufficient length", func(t *testing.T) { 385 // The implementation uses 32-character passwords 386 // We can verify this indirectly through the database 387 db := setupTestDB(t) 388 defer func() { 389 if err := db.Close(); err != nil { 390 t.Logf("Failed to close database: %v", err) 391 } 392 }() 393 394 repo := postgres.NewCommunityRepository(db) 395 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 396 397 // Create a community with a known password 398 testPassword := "test-password-with-32-chars--" 399 if len(testPassword) < 32 { 400 testPassword = testPassword + strings.Repeat("x", 32-len(testPassword)) 401 } 402 403 community := &communities.Community{ 404 DID: generateTestDID(uniqueSuffix), 405 Handle: fmt.Sprintf("test-pwd-len-%s.community.test.local", uniqueSuffix), 406 Name: "test-pwd-len", 407 DisplayName: "Test Password Length", 408 Description: "Testing password length requirements", 409 OwnerDID: "did:web:test.local", 410 CreatedByDID: "did:plc:testuser", 411 HostedByDID: "did:web:test.local", 412 PDSEmail: fmt.Sprintf("test-pwd-len-%s@test.local", uniqueSuffix), 413 PDSPassword: testPassword, 414 PDSAccessToken: "test-access-token", 415 PDSRefreshToken: "test-refresh-token", 416 PDSURL: "http://localhost:3001", 417 Visibility: "public", 418 AllowExternalDiscovery: true, 419 CreatedAt: time.Now(), 420 UpdatedAt: time.Now(), 421 } 422 423 created, err := repo.Create(ctx, community) 424 if err != nil { 425 t.Fatalf("Failed to create community: %v", err) 426 } 427 428 retrieved, err := repo.GetByDID(ctx, created.DID) 429 if err != nil { 430 t.Fatalf("Failed to retrieve community: %v", err) 431 } 432 433 // Verify password is stored correctly and has sufficient length 434 if len(retrieved.PDSPassword) < 32 { 435 t.Errorf("Password too short: expected >= 32 characters, got %d", len(retrieved.PDSPassword)) 436 } 437 438 t.Logf("✅ Password length verified: %d characters", len(retrieved.PDSPassword)) 439 }) 440} 441 442// TestConcurrentProvisioning verifies thread-safety during community creation 443// Critical: Prevents race conditions that could create duplicate communities 444func TestConcurrentProvisioning(t *testing.T) { 445 db := setupTestDB(t) 446 defer func() { 447 if err := db.Close(); err != nil { 448 t.Logf("Failed to close database: %v", err) 449 } 450 }() 451 452 repo := postgres.NewCommunityRepository(db) 453 ctx := context.Background() 454 455 t.Run("prevents duplicate community creation", func(t *testing.T) { 456 // Try to create the same community concurrently 457 const numGoroutines = 10 458 sameName := fmt.Sprintf("concurrent-test-%d", time.Now().UnixNano()) 459 460 // Channel to collect results 461 type result struct { 462 community *communities.Community 463 err error 464 } 465 results := make(chan result, numGoroutines) 466 467 // Launch concurrent creation attempts 468 for i := 0; i < numGoroutines; i++ { 469 go func(idx int) { 470 uniqueSuffix := fmt.Sprintf("%d-%d", time.Now().UnixNano(), idx) 471 community := &communities.Community{ 472 DID: generateTestDID(uniqueSuffix), 473 Handle: fmt.Sprintf("%s.community.test.local", sameName), 474 Name: sameName, 475 DisplayName: "Concurrent Test", 476 Description: "Testing concurrent creation", 477 OwnerDID: "did:web:test.local", 478 CreatedByDID: "did:plc:testuser", 479 HostedByDID: "did:web:test.local", 480 PDSEmail: fmt.Sprintf("%s-%s@test.local", sameName, uniqueSuffix), 481 PDSPassword: "test-password-concurrent", 482 PDSAccessToken: fmt.Sprintf("access-token-%d", idx), 483 PDSRefreshToken: fmt.Sprintf("refresh-token-%d", idx), 484 PDSURL: "http://localhost:3001", 485 Visibility: "public", 486 AllowExternalDiscovery: true, 487 CreatedAt: time.Now(), 488 UpdatedAt: time.Now(), 489 } 490 491 created, err := repo.Create(ctx, community) 492 results <- result{community: created, err: err} 493 }(i) 494 } 495 496 // Collect results 497 successCount := 0 498 duplicateErrorCount := 0 499 500 for i := 0; i < numGoroutines; i++ { 501 res := <-results 502 if res.err == nil { 503 successCount++ 504 } else if strings.Contains(res.err.Error(), "duplicate") || 505 strings.Contains(res.err.Error(), "unique") || 506 strings.Contains(res.err.Error(), "already exists") { 507 duplicateErrorCount++ 508 } else { 509 t.Logf("Unexpected error: %v", res.err) 510 } 511 } 512 513 // We expect exactly one success and the rest to fail with duplicate errors 514 // OR all to succeed with unique DIDs (depending on implementation) 515 t.Logf("Results: %d successful, %d duplicate errors", successCount, duplicateErrorCount) 516 517 // At minimum, we should have some creations succeed 518 if successCount == 0 { 519 t.Error("Expected at least one successful community creation") 520 } 521 522 // If we have duplicate errors, that's good - it means uniqueness is enforced 523 if duplicateErrorCount > 0 { 524 t.Logf("✅ Database correctly prevents duplicate handles: %d duplicate errors", duplicateErrorCount) 525 } 526 }) 527 528 t.Run("handles concurrent reads safely", func(t *testing.T) { 529 // Create a test community 530 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 531 community := &communities.Community{ 532 DID: generateTestDID(uniqueSuffix), 533 Handle: fmt.Sprintf("read-test-%s.community.test.local", uniqueSuffix), 534 Name: "read-test", 535 DisplayName: "Read Test", 536 Description: "Testing concurrent reads", 537 OwnerDID: "did:web:test.local", 538 CreatedByDID: "did:plc:testuser", 539 HostedByDID: "did:web:test.local", 540 PDSEmail: fmt.Sprintf("read-test-%s@test.local", uniqueSuffix), 541 PDSPassword: "test-password-reads", 542 PDSAccessToken: "access-token", 543 PDSRefreshToken: "refresh-token", 544 PDSURL: "http://localhost:3001", 545 Visibility: "public", 546 AllowExternalDiscovery: true, 547 CreatedAt: time.Now(), 548 UpdatedAt: time.Now(), 549 } 550 551 created, err := repo.Create(ctx, community) 552 if err != nil { 553 t.Fatalf("Failed to create test community: %v", err) 554 } 555 556 // Now read it concurrently 557 const numReaders = 20 558 results := make(chan error, numReaders) 559 560 for i := 0; i < numReaders; i++ { 561 go func() { 562 _, err := repo.GetByDID(ctx, created.DID) 563 results <- err 564 }() 565 } 566 567 // All reads should succeed 568 failCount := 0 569 for i := 0; i < numReaders; i++ { 570 if err := <-results; err != nil { 571 failCount++ 572 t.Logf("Read %d failed: %v", i, err) 573 } 574 } 575 576 if failCount > 0 { 577 t.Errorf("Expected all concurrent reads to succeed, but %d failed", failCount) 578 } else { 579 t.Logf("✅ All %d concurrent reads succeeded", numReaders) 580 } 581 }) 582} 583 584// TestPDSNetworkFailures verifies graceful handling of PDS network issues 585// Critical: Ensures service doesn't crash or leak resources on PDS failures 586func TestPDSNetworkFailures(t *testing.T) { 587 ctx := context.Background() 588 589 t.Run("handles invalid PDS URL", func(t *testing.T) { 590 // Invalid URL should fail gracefully 591 invalidURLs := []string{ 592 "not-a-url", 593 "ftp://invalid-protocol.com", 594 "http://", 595 "://missing-scheme", 596 "", 597 } 598 599 for _, invalidURL := range invalidURLs { 600 provisioner := communities.NewPDSAccountProvisioner("test.local", invalidURL) 601 _, err := provisioner.ProvisionCommunityAccount(ctx, "testcommunity") 602 603 if err == nil { 604 t.Errorf("Expected error for invalid PDS URL %q, got nil", invalidURL) 605 } 606 607 // Should get a clear error about PDS failure 608 if !strings.Contains(err.Error(), "PDS") && !strings.Contains(err.Error(), "failed") { 609 t.Logf("Error message could be clearer for URL %q: %v", invalidURL, err) 610 } 611 612 t.Logf("✅ Invalid URL %q correctly rejected: %v", invalidURL, err) 613 } 614 }) 615 616 t.Run("handles unreachable PDS server", func(t *testing.T) { 617 // Use a port that's guaranteed to be unreachable 618 unreachablePDS := "http://localhost:9999" 619 provisioner := communities.NewPDSAccountProvisioner("test.local", unreachablePDS) 620 621 _, err := provisioner.ProvisionCommunityAccount(ctx, "testcommunity") 622 623 if err == nil { 624 t.Error("Expected error for unreachable PDS, got nil") 625 } 626 627 // Should get connection error 628 if !strings.Contains(err.Error(), "PDS account creation failed") { 629 t.Logf("Error for unreachable PDS: %v", err) 630 } 631 632 t.Logf("✅ Unreachable PDS handled gracefully: %v", err) 633 }) 634 635 t.Run("handles timeout scenarios", func(t *testing.T) { 636 // Create a context with a very short timeout 637 timeoutCtx, cancel := context.WithTimeout(ctx, 1) 638 defer cancel() 639 640 provisioner := communities.NewPDSAccountProvisioner("test.local", "http://localhost:3001") 641 _, err := provisioner.ProvisionCommunityAccount(timeoutCtx, "testcommunity") 642 643 // Should either timeout or fail to connect (since PDS isn't running) 644 if err == nil { 645 t.Error("Expected timeout or connection error, got nil") 646 } 647 648 t.Logf("✅ Timeout handled: %v", err) 649 }) 650 651 t.Run("FetchPDSDID handles invalid URLs", func(t *testing.T) { 652 invalidURLs := []string{ 653 "not-a-url", 654 "http://", 655 "", 656 } 657 658 for _, invalidURL := range invalidURLs { 659 _, err := communities.FetchPDSDID(ctx, invalidURL) 660 661 if err == nil { 662 t.Errorf("FetchPDSDID should fail for invalid URL %q", invalidURL) 663 } 664 665 t.Logf("✅ FetchPDSDID rejected invalid URL %q: %v", invalidURL, err) 666 } 667 }) 668 669 t.Run("FetchPDSDID handles unreachable server", func(t *testing.T) { 670 unreachablePDS := "http://localhost:9998" 671 _, err := communities.FetchPDSDID(ctx, unreachablePDS) 672 673 if err == nil { 674 t.Error("Expected error for unreachable PDS") 675 } 676 677 if !strings.Contains(err.Error(), "failed to describe server") { 678 t.Errorf("Expected 'failed to describe server' error, got: %v", err) 679 } 680 681 t.Logf("✅ FetchPDSDID handles unreachable server: %v", err) 682 }) 683 684 t.Run("FetchPDSDID handles timeout", func(t *testing.T) { 685 timeoutCtx, cancel := context.WithTimeout(ctx, 1) 686 defer cancel() 687 688 _, err := communities.FetchPDSDID(timeoutCtx, "http://localhost:3001") 689 690 // Should timeout or fail to connect 691 if err == nil { 692 t.Error("Expected timeout or connection error") 693 } 694 695 t.Logf("✅ FetchPDSDID timeout handled: %v", err) 696 }) 697} 698 699// TestTokenValidation verifies that PDS-returned tokens meet requirements 700// Critical for P0: Tokens must be valid JWTs that can be used for authentication 701func TestTokenValidation(t *testing.T) { 702 db := setupTestDB(t) 703 defer func() { 704 if err := db.Close(); err != nil { 705 t.Logf("Failed to close database: %v", err) 706 } 707 }() 708 709 repo := postgres.NewCommunityRepository(db) 710 ctx := context.Background() 711 712 t.Run("validates access token storage", func(t *testing.T) { 713 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 714 715 // Create a community with realistic-looking tokens 716 // Real atProto JWTs are typically 200+ characters 717 accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOnRlc3QiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 718 refreshToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOnRlc3QiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTUxNjIzOTAyMn0.different_signature_here" 719 720 community := &communities.Community{ 721 DID: generateTestDID(uniqueSuffix), 722 Handle: fmt.Sprintf("token-test-%s.community.test.local", uniqueSuffix), 723 Name: "token-test", 724 DisplayName: "Token Test", 725 Description: "Testing token storage", 726 OwnerDID: "did:web:test.local", 727 CreatedByDID: "did:plc:testuser", 728 HostedByDID: "did:web:test.local", 729 PDSEmail: fmt.Sprintf("token-test-%s@test.local", uniqueSuffix), 730 PDSPassword: "test-password-tokens", 731 PDSAccessToken: accessToken, 732 PDSRefreshToken: refreshToken, 733 PDSURL: "http://localhost:3001", 734 Visibility: "public", 735 AllowExternalDiscovery: true, 736 CreatedAt: time.Now(), 737 UpdatedAt: time.Now(), 738 } 739 740 created, err := repo.Create(ctx, community) 741 if err != nil { 742 t.Fatalf("Failed to create community: %v", err) 743 } 744 745 // Retrieve and verify tokens 746 retrieved, err := repo.GetByDID(ctx, created.DID) 747 if err != nil { 748 t.Fatalf("Failed to retrieve community: %v", err) 749 } 750 751 // Verify access token stored correctly 752 if retrieved.PDSAccessToken != accessToken { 753 t.Errorf("Access token mismatch: expected %q, got %q", accessToken, retrieved.PDSAccessToken) 754 } 755 756 // Verify refresh token stored correctly 757 if retrieved.PDSRefreshToken != refreshToken { 758 t.Errorf("Refresh token mismatch: expected %q, got %q", refreshToken, retrieved.PDSRefreshToken) 759 } 760 761 // Verify tokens are not empty 762 if retrieved.PDSAccessToken == "" { 763 t.Error("Access token should not be empty") 764 } 765 if retrieved.PDSRefreshToken == "" { 766 t.Error("Refresh token should not be empty") 767 } 768 769 // Verify tokens have reasonable length (JWTs are typically 100+ chars) 770 if len(retrieved.PDSAccessToken) < 50 { 771 t.Errorf("Access token seems too short: %d characters", len(retrieved.PDSAccessToken)) 772 } 773 if len(retrieved.PDSRefreshToken) < 50 { 774 t.Errorf("Refresh token seems too short: %d characters", len(retrieved.PDSRefreshToken)) 775 } 776 777 t.Logf("✅ Tokens stored and retrieved correctly:") 778 t.Logf(" Access token: %d chars", len(retrieved.PDSAccessToken)) 779 t.Logf(" Refresh token: %d chars", len(retrieved.PDSRefreshToken)) 780 }) 781 782 t.Run("handles empty tokens gracefully", func(t *testing.T) { 783 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()+1) 784 785 community := &communities.Community{ 786 DID: generateTestDID(uniqueSuffix), 787 Handle: fmt.Sprintf("empty-token-%s.community.test.local", uniqueSuffix), 788 Name: "empty-token", 789 DisplayName: "Empty Token Test", 790 Description: "Testing empty token handling", 791 OwnerDID: "did:web:test.local", 792 CreatedByDID: "did:plc:testuser", 793 HostedByDID: "did:web:test.local", 794 PDSEmail: fmt.Sprintf("empty-token-%s@test.local", uniqueSuffix), 795 PDSPassword: "test-password", 796 PDSAccessToken: "", // Empty 797 PDSRefreshToken: "", // Empty 798 PDSURL: "http://localhost:3001", 799 Visibility: "public", 800 AllowExternalDiscovery: true, 801 CreatedAt: time.Now(), 802 UpdatedAt: time.Now(), 803 } 804 805 created, err := repo.Create(ctx, community) 806 if err != nil { 807 t.Fatalf("Failed to create community with empty tokens: %v", err) 808 } 809 810 retrieved, err := repo.GetByDID(ctx, created.DID) 811 if err != nil { 812 t.Fatalf("Failed to retrieve community: %v", err) 813 } 814 815 // Empty tokens should be preserved 816 if retrieved.PDSAccessToken != "" { 817 t.Errorf("Expected empty access token, got: %q", retrieved.PDSAccessToken) 818 } 819 if retrieved.PDSRefreshToken != "" { 820 t.Errorf("Expected empty refresh token, got: %q", retrieved.PDSRefreshToken) 821 } 822 823 t.Logf("✅ Empty tokens handled correctly (NULL/empty string)") 824 }) 825 826 t.Run("validates token encryption in database", func(t *testing.T) { 827 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()+2) 828 829 // Use distinct tokens so we can verify they're encrypted separately 830 accessToken := "access-token-should-be-encrypted-" + uniqueSuffix 831 refreshToken := "refresh-token-should-be-encrypted-" + uniqueSuffix 832 833 community := &communities.Community{ 834 DID: generateTestDID(uniqueSuffix), 835 Handle: fmt.Sprintf("encrypted-token-%s.community.test.local", uniqueSuffix), 836 Name: "encrypted-token", 837 DisplayName: "Encrypted Token Test", 838 Description: "Testing token encryption", 839 OwnerDID: "did:web:test.local", 840 CreatedByDID: "did:plc:testuser", 841 HostedByDID: "did:web:test.local", 842 PDSEmail: fmt.Sprintf("encrypted-token-%s@test.local", uniqueSuffix), 843 PDSPassword: "test-password", 844 PDSAccessToken: accessToken, 845 PDSRefreshToken: refreshToken, 846 PDSURL: "http://localhost:3001", 847 Visibility: "public", 848 AllowExternalDiscovery: true, 849 CreatedAt: time.Now(), 850 UpdatedAt: time.Now(), 851 } 852 853 created, err := repo.Create(ctx, community) 854 if err != nil { 855 t.Fatalf("Failed to create community: %v", err) 856 } 857 858 retrieved, err := repo.GetByDID(ctx, created.DID) 859 if err != nil { 860 t.Fatalf("Failed to retrieve community: %v", err) 861 } 862 863 // Verify tokens are decrypted correctly 864 if retrieved.PDSAccessToken != accessToken { 865 t.Errorf("Access token decryption failed: expected %q, got %q", accessToken, retrieved.PDSAccessToken) 866 } 867 if retrieved.PDSRefreshToken != refreshToken { 868 t.Errorf("Refresh token decryption failed: expected %q, got %q", refreshToken, retrieved.PDSRefreshToken) 869 } 870 871 t.Logf("✅ Tokens encrypted/decrypted correctly") 872 }) 873}