A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/core/communities" 5 "Coves/internal/db/postgres" 6 "bytes" 7 "context" 8 "encoding/json" 9 "fmt" 10 "io" 11 "net/http" 12 "strings" 13 "testing" 14 "time" 15) 16 17// TestCommunityService_CreateWithRealPDS tests the complete service layer flow 18// using a REAL local PDS. This verifies: 19// - Password generation happens in provisioner (not hardcoded test passwords) 20// - PDS account creation works (real com.atproto.server.createAccount) 21// - Write-forward to community's own repository succeeds 22// - Credentials flow correctly: PDS → service → repository 23// - Complete atProto write-forward architecture 24// 25// This test fills the gap between: 26// - Unit tests (direct DB writes, bypass PDS) 27// - E2E tests (full HTTP + Jetstream flow) 28func TestCommunityService_CreateWithRealPDS(t *testing.T) { 29 if testing.Short() { 30 t.Skip("Skipping integration test in short mode - requires PDS") 31 } 32 33 // Check if PDS is running 34 pdsURL := "http://localhost:3001" 35 healthResp, err := http.Get(pdsURL + "/xrpc/_health") 36 if err != nil { 37 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err) 38 } 39 defer func() { 40 if closeErr := healthResp.Body.Close(); closeErr != nil { 41 t.Logf("Failed to close health response: %v", closeErr) 42 } 43 }() 44 45 // Setup test database 46 db := setupTestDB(t) 47 defer func() { 48 if err := db.Close(); err != nil { 49 t.Logf("Failed to close database: %v", err) 50 } 51 }() 52 53 ctx := context.Background() 54 repo := postgres.NewCommunityRepository(db) 55 56 t.Run("creates community with real PDS provisioning", func(t *testing.T) { 57 // Create provisioner and service (production code path) 58 // Use coves.social domain (configured in PDS_SERVICE_HANDLE_DOMAINS as .communities.coves.social) 59 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 60 service := communities.NewCommunityService( 61 repo, 62 pdsURL, 63 "did:web:coves.social", 64 "coves.social", 65 provisioner, 66 ) 67 68 // Generate unique community name (keep short for DNS label limit) 69 // Must start with letter, can contain alphanumeric and hyphens 70 uniqueName := fmt.Sprintf("svc%d", time.Now().UnixNano()%1000000) 71 72 // Create community via service (FULL PRODUCTION CODE PATH) 73 t.Logf("Creating community via service.CreateCommunity()...") 74 community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 75 Name: uniqueName, 76 DisplayName: "Test Community", 77 Description: "Integration test community with real PDS", 78 Visibility: "public", 79 CreatedByDID: "did:plc:testuser123", 80 HostedByDID: "did:web:coves.social", 81 AllowExternalDiscovery: true, 82 }) 83 84 if err != nil { 85 t.Fatalf("Failed to create community: %v", err) 86 } 87 88 t.Logf("✅ Community created: %s", community.DID) 89 90 // CRITICAL: Verify password was generated by provisioner (not hardcoded) 91 if len(community.PDSPassword) < 32 { 92 t.Errorf("Password too short: expected >= 32 chars from provisioner, got %d", len(community.PDSPassword)) 93 } 94 95 // Verify password is not empty 96 if community.PDSPassword == "" { 97 t.Error("Password should not be empty") 98 } 99 100 // Verify password is not a known test password 101 testPasswords := []string{"test-password", "password123", "admin", ""} 102 for _, testPwd := range testPasswords { 103 if community.PDSPassword == testPwd { 104 t.Errorf("Password appears to be hardcoded test password: %s", testPwd) 105 } 106 } 107 108 t.Logf("✅ Password generated by provisioner: %d chars", len(community.PDSPassword)) 109 110 // Verify DID is real (did:plc:xxx from PDS) 111 if !strings.HasPrefix(community.DID, "did:plc:") { 112 t.Errorf("Expected real PLC DID from PDS, got: %s", community.DID) 113 } 114 115 t.Logf("✅ Real DID generated: %s", community.DID) 116 117 // Verify handle format 118 expectedHandle := fmt.Sprintf("%s.communities.coves.social", uniqueName) 119 if community.Handle != expectedHandle { 120 t.Errorf("Expected handle %s, got %s", expectedHandle, community.Handle) 121 } 122 123 t.Logf("✅ Handle generated correctly: %s", community.Handle) 124 125 // Verify tokens are present (from PDS) 126 if community.PDSAccessToken == "" { 127 t.Error("Access token should not be empty") 128 } 129 if community.PDSRefreshToken == "" { 130 t.Error("Refresh token should not be empty") 131 } 132 133 // Verify tokens are JWT format (3 parts separated by dots) 134 accessParts := strings.Split(community.PDSAccessToken, ".") 135 if len(accessParts) != 3 { 136 t.Errorf("Access token should be JWT format (3 parts), got %d parts", len(accessParts)) 137 } 138 139 t.Logf("✅ JWT tokens received from PDS") 140 141 // Verify record URI points to community's own repository (V2 architecture) 142 expectedURIPrefix := fmt.Sprintf("at://%s/social.coves.community.profile/self", community.DID) 143 if community.RecordURI != expectedURIPrefix { 144 t.Errorf("Expected record URI %s, got %s", expectedURIPrefix, community.RecordURI) 145 } 146 147 t.Logf("✅ Record URI points to community's own repo: %s", community.RecordURI) 148 149 // Verify V2 ownership model (community owns itself) 150 if community.OwnerDID != community.DID { 151 t.Errorf("V2: community should own itself. Expected OwnerDID=%s, got %s", community.DID, community.OwnerDID) 152 } 153 154 t.Logf("✅ V2 ownership: community owns itself") 155 156 // CRITICAL: Verify credentials were persisted to database WITH ENCRYPTION 157 retrieved, err := repo.GetByDID(ctx, community.DID) 158 if err != nil { 159 t.Fatalf("Failed to retrieve community from DB: %v", err) 160 } 161 162 // Verify password roundtrip (encrypted → decrypted) 163 if retrieved.PDSPassword != community.PDSPassword { 164 t.Error("Password not persisted correctly (encryption/decryption failed)") 165 } 166 167 // Verify tokens roundtrip 168 if retrieved.PDSAccessToken != community.PDSAccessToken { 169 t.Error("Access token not persisted correctly") 170 } 171 if retrieved.PDSRefreshToken != community.PDSRefreshToken { 172 t.Error("Refresh token not persisted correctly") 173 } 174 175 t.Logf("✅ Credentials persisted to DB with encryption") 176 177 // Verify password is encrypted at rest in database 178 var encryptedPassword []byte 179 query := ` 180 SELECT pds_password_encrypted 181 FROM communities 182 WHERE did = $1 183 ` 184 if err := db.QueryRowContext(ctx, query, community.DID).Scan(&encryptedPassword); err != nil { 185 t.Fatalf("Failed to query encrypted password: %v", err) 186 } 187 188 // Verify NOT stored as plaintext 189 if string(encryptedPassword) == community.PDSPassword { 190 t.Error("CRITICAL: Password stored as plaintext in database!") 191 } 192 193 // Verify encrypted data exists 194 if len(encryptedPassword) == 0 { 195 t.Error("Encrypted password should have data") 196 } 197 198 t.Logf("✅ Password encrypted at rest in database") 199 200 t.Logf("✅ COMPLETE TEST PASSED: Full write-forward architecture verified") 201 }) 202 203 t.Run("handles PDS errors gracefully", func(t *testing.T) { 204 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 205 service := communities.NewCommunityService( 206 repo, 207 pdsURL, 208 "did:web:coves.social", 209 "coves.social", 210 provisioner, 211 ) 212 213 // Try to create community with invalid name (should fail validation before PDS) 214 _, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 215 Name: "", // Empty name 216 DisplayName: "Invalid Community", 217 Visibility: "public", 218 CreatedByDID: "did:plc:testuser123", 219 HostedByDID: "did:web:coves.social", 220 AllowExternalDiscovery: true, 221 }) 222 223 if err == nil { 224 t.Error("Expected validation error for empty name") 225 } 226 227 if !strings.Contains(err.Error(), "name") { 228 t.Errorf("Expected 'name' error, got: %v", err) 229 } 230 231 t.Logf("✅ Validation errors handled correctly") 232 }) 233 234 t.Run("validates DNS label limits", func(t *testing.T) { 235 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 236 service := communities.NewCommunityService( 237 repo, 238 pdsURL, 239 "did:web:coves.social", 240 "coves.social", 241 provisioner, 242 ) 243 244 // Try 64-char name (exceeds DNS limit of 63) 245 longName := strings.Repeat("a", 64) 246 247 _, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 248 Name: longName, 249 DisplayName: "Long Name Test", 250 Visibility: "public", 251 CreatedByDID: "did:plc:testuser123", 252 HostedByDID: "did:web:coves.social", 253 AllowExternalDiscovery: true, 254 }) 255 256 if err == nil { 257 t.Error("Expected error for 64-char name (DNS limit is 63)") 258 } 259 260 if !strings.Contains(err.Error(), "63") { 261 t.Errorf("Expected DNS limit error mentioning '63', got: %v", err) 262 } 263 264 t.Logf("✅ DNS label limits enforced") 265 }) 266} 267 268// TestCommunityService_UpdateWithRealPDS tests the V2 update flow 269// This is CRITICAL - currently has ZERO test coverage in unit tests! 270// 271// Verifies: 272// - Updates use community's OWN credentials (not instance credentials) 273// - Writes to community's repository (at://community_did/...) 274// - Authorization checks (only creator can update) 275// - Record rkey is always "self" for V2 276func TestCommunityService_UpdateWithRealPDS(t *testing.T) { 277 if testing.Short() { 278 t.Skip("Skipping integration test in short mode - requires PDS") 279 } 280 281 // Check if PDS is running 282 pdsURL := "http://localhost:3001" 283 healthResp, err := http.Get(pdsURL + "/xrpc/_health") 284 if err != nil { 285 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err) 286 } 287 defer func() { 288 if closeErr := healthResp.Body.Close(); closeErr != nil { 289 t.Logf("Failed to close health response: %v", closeErr) 290 } 291 }() 292 293 // Setup test database 294 db := setupTestDB(t) 295 defer func() { 296 if err := db.Close(); err != nil { 297 t.Logf("Failed to close database: %v", err) 298 } 299 }() 300 301 ctx := context.Background() 302 repo := postgres.NewCommunityRepository(db) 303 304 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 305 service := communities.NewCommunityService( 306 repo, 307 pdsURL, 308 "did:web:coves.social", 309 "coves.social", 310 provisioner, 311 ) 312 313 t.Run("updates community with real PDS", func(t *testing.T) { 314 // First, create a community 315 uniqueName := fmt.Sprintf("upd%d", time.Now().UnixNano()%1000000) 316 creatorDID := "did:plc:updatetestuser" 317 318 t.Logf("Creating community to update...") 319 community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 320 Name: uniqueName, 321 DisplayName: "Original Display Name", 322 Description: "Original description", 323 Visibility: "public", 324 CreatedByDID: creatorDID, 325 HostedByDID: "did:web:coves.social", 326 AllowExternalDiscovery: true, 327 }) 328 329 if err != nil { 330 t.Fatalf("Failed to create community: %v", err) 331 } 332 333 t.Logf("✅ Community created: %s", community.DID) 334 335 // Now update it 336 newDisplayName := "Updated Display Name" 337 newDescription := "Updated description via V2 write-forward" 338 newVisibility := "unlisted" 339 340 t.Logf("Updating community via service.UpdateCommunity()...") 341 updated, err := service.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 342 CommunityDID: community.DID, 343 UpdatedByDID: creatorDID, // Same as creator - should be authorized 344 DisplayName: &newDisplayName, 345 Description: &newDescription, 346 Visibility: &newVisibility, 347 AllowExternalDiscovery: nil, // Don't change 348 }) 349 350 if err != nil { 351 t.Fatalf("Failed to update community: %v", err) 352 } 353 354 t.Logf("✅ Community updated via PDS") 355 356 // Verify updates were applied 357 if updated.DisplayName != newDisplayName { 358 t.Errorf("Expected display name %s, got %s", newDisplayName, updated.DisplayName) 359 } 360 if updated.Description != newDescription { 361 t.Errorf("Expected description %s, got %s", newDescription, updated.Description) 362 } 363 if updated.Visibility != newVisibility { 364 t.Errorf("Expected visibility %s, got %s", newVisibility, updated.Visibility) 365 } 366 367 t.Logf("✅ Updates applied correctly") 368 369 // Verify record URI still points to community's own repo with rkey "self" 370 expectedURIPrefix := fmt.Sprintf("at://%s/social.coves.community.profile/self", community.DID) 371 if updated.RecordURI != expectedURIPrefix { 372 t.Errorf("Expected record URI %s, got %s", expectedURIPrefix, updated.RecordURI) 373 } 374 375 t.Logf("✅ Record URI correct (uses community's repo)") 376 377 // Verify record CID changed (new version) 378 if updated.RecordCID == community.RecordCID { 379 t.Error("Expected record CID to change after update") 380 } 381 382 t.Logf("✅ Record CID updated (new version)") 383 }) 384 385 t.Run("rejects unauthorized updates", func(t *testing.T) { 386 // Create a community 387 uniqueName := fmt.Sprintf("auth%d", time.Now().UnixNano()%1000000) 388 creatorDID := "did:plc:creator123" 389 390 community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 391 Name: uniqueName, 392 DisplayName: "Auth Test Community", 393 Visibility: "public", 394 CreatedByDID: creatorDID, 395 HostedByDID: "did:web:coves.social", 396 AllowExternalDiscovery: true, 397 }) 398 399 if err != nil { 400 t.Fatalf("Failed to create community: %v", err) 401 } 402 403 // Try to update as different user 404 differentUserDID := "did:plc:nottheowner" 405 newDisplayName := "Hacked Display Name" 406 407 _, err = service.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 408 CommunityDID: community.DID, 409 UpdatedByDID: differentUserDID, // NOT the creator 410 DisplayName: &newDisplayName, 411 }) 412 413 if err == nil { 414 t.Error("Expected authorization error for non-creator update") 415 } 416 417 if !strings.Contains(strings.ToLower(err.Error()), "unauthorized") { 418 t.Errorf("Expected 'unauthorized' error, got: %v", err) 419 } 420 421 t.Logf("✅ Unauthorized updates rejected") 422 }) 423 424 t.Run("handles missing PDS credentials", func(t *testing.T) { 425 // Create a community manually in DB without PDS credentials 426 // (simulating a federated community indexed from another instance) 427 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 428 communityDID := generateTestDID(uniqueSuffix) 429 430 federatedCommunity := &communities.Community{ 431 DID: communityDID, 432 Handle: fmt.Sprintf("federated-%s.external.social", uniqueSuffix), 433 Name: "federated-test", 434 OwnerDID: communityDID, 435 CreatedByDID: "did:plc:externaluser", 436 HostedByDID: "did:web:external.social", 437 Visibility: "public", 438 // No PDS credentials - this is a federated community 439 CreatedAt: time.Now(), 440 UpdatedAt: time.Now(), 441 } 442 443 _, err := repo.Create(ctx, federatedCommunity) 444 if err != nil { 445 t.Fatalf("Failed to create federated community: %v", err) 446 } 447 448 // Try to update it - should fail because we don't have credentials 449 newDisplayName := "Cannot Update This" 450 _, err = service.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 451 CommunityDID: communityDID, 452 UpdatedByDID: "did:plc:externaluser", 453 DisplayName: &newDisplayName, 454 }) 455 456 if err == nil { 457 t.Error("Expected error when updating community without PDS credentials") 458 } 459 460 if !strings.Contains(err.Error(), "missing PDS credentials") { 461 t.Logf("Error message: %v", err) 462 } 463 464 t.Logf("✅ Missing credentials handled gracefully") 465 }) 466} 467 468// TestPasswordAuthentication verifies that generated passwords work for PDS authentication 469// This is CRITICAL for P0: passwords must be recoverable for session renewal 470func TestPasswordAuthentication(t *testing.T) { 471 if testing.Short() { 472 t.Skip("Skipping integration test in short mode - requires PDS") 473 } 474 475 // Check if PDS is running 476 pdsURL := "http://localhost:3001" 477 healthResp, err := http.Get(pdsURL + "/xrpc/_health") 478 if err != nil { 479 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err) 480 } 481 defer func() { 482 if closeErr := healthResp.Body.Close(); closeErr != nil { 483 t.Logf("Failed to close health response: %v", closeErr) 484 } 485 }() 486 487 // Setup test database 488 db := setupTestDB(t) 489 defer func() { 490 if err := db.Close(); err != nil { 491 t.Logf("Failed to close database: %v", err) 492 } 493 }() 494 495 ctx := context.Background() 496 repo := postgres.NewCommunityRepository(db) 497 498 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 499 service := communities.NewCommunityService( 500 repo, 501 pdsURL, 502 "did:web:coves.social", 503 "coves.social", 504 provisioner, 505 ) 506 507 t.Run("generated password works for session creation", func(t *testing.T) { 508 // Create a community with PDS-generated password 509 uniqueName := fmt.Sprintf("pwd%d", time.Now().UnixNano()%1000000) 510 511 t.Logf("Creating community with generated password...") 512 community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 513 Name: uniqueName, 514 DisplayName: "Password Auth Test", 515 Visibility: "public", 516 CreatedByDID: "did:plc:testuser", 517 HostedByDID: "did:web:coves.social", 518 AllowExternalDiscovery: true, 519 }) 520 521 if err != nil { 522 t.Fatalf("Failed to create community: %v", err) 523 } 524 525 t.Logf("✅ Community created with password: %d chars", len(community.PDSPassword)) 526 527 // Retrieve from DB to get decrypted password 528 retrieved, err := repo.GetByDID(ctx, community.DID) 529 if err != nil { 530 t.Fatalf("Failed to retrieve community: %v", err) 531 } 532 533 t.Logf("✅ Password retrieved from DB (decrypted): %d chars", len(retrieved.PDSPassword)) 534 535 // Now try to authenticate with the password via com.atproto.server.createSession 536 // This simulates what we'd do for token renewal 537 sessionPayload := map[string]interface{}{ 538 "identifier": retrieved.Handle, // Use handle for login 539 "password": retrieved.PDSPassword, 540 } 541 542 payloadBytes, err := json.Marshal(sessionPayload) 543 if err != nil { 544 t.Fatalf("Failed to marshal session payload: %v", err) 545 } 546 547 sessionReq, err := http.NewRequestWithContext(ctx, "POST", 548 pdsURL+"/xrpc/com.atproto.server.createSession", 549 bytes.NewReader(payloadBytes)) 550 if err != nil { 551 t.Fatalf("Failed to create session request: %v", err) 552 } 553 sessionReq.Header.Set("Content-Type", "application/json") 554 555 client := &http.Client{Timeout: 10 * time.Second} 556 resp, err := client.Do(sessionReq) 557 if err != nil { 558 t.Fatalf("Failed to create session: %v", err) 559 } 560 defer func() { 561 if closeErr := resp.Body.Close(); closeErr != nil { 562 t.Logf("Failed to close response body: %v", closeErr) 563 } 564 }() 565 566 body, err := io.ReadAll(resp.Body) 567 if err != nil { 568 t.Fatalf("Failed to read response body: %v", err) 569 } 570 571 if resp.StatusCode != http.StatusOK { 572 t.Fatalf("Session creation failed with status %d: %s", resp.StatusCode, string(body)) 573 } 574 575 // Verify we got new tokens 576 var sessionResp struct { 577 AccessJwt string `json:"accessJwt"` 578 RefreshJwt string `json:"refreshJwt"` 579 DID string `json:"did"` 580 } 581 582 if err := json.Unmarshal(body, &sessionResp); err != nil { 583 t.Fatalf("Failed to parse session response: %v", err) 584 } 585 586 if sessionResp.AccessJwt == "" { 587 t.Error("Expected new access token from session") 588 } 589 if sessionResp.RefreshJwt == "" { 590 t.Error("Expected new refresh token from session") 591 } 592 if sessionResp.DID != community.DID { 593 t.Errorf("Expected session DID %s, got %s", community.DID, sessionResp.DID) 594 } 595 596 t.Logf("✅ Password authentication successful!") 597 t.Logf(" - New access token: %d chars", len(sessionResp.AccessJwt)) 598 t.Logf(" - New refresh token: %d chars", len(sessionResp.RefreshJwt)) 599 t.Logf(" - Session DID: %s", sessionResp.DID) 600 601 t.Logf("✅ CRITICAL TEST PASSED: Password encryption enables session renewal") 602 }) 603}