A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "io" 9 "net/http" 10 "strings" 11 "testing" 12 "time" 13 14 "Coves/internal/core/communities" 15 "Coves/internal/db/postgres" 16) 17 18// TestCommunityService_CreateWithRealPDS tests the complete service layer flow 19// using a REAL local PDS. This verifies: 20// - Password generation happens in provisioner (not hardcoded test passwords) 21// - PDS account creation works (real com.atproto.server.createAccount) 22// - Write-forward to community's own repository succeeds 23// - Credentials flow correctly: PDS → service → repository 24// - Complete atProto write-forward architecture 25// 26// This test fills the gap between: 27// - Unit tests (direct DB writes, bypass PDS) 28// - E2E tests (full HTTP + Jetstream flow) 29func TestCommunityService_CreateWithRealPDS(t *testing.T) { 30 if testing.Short() { 31 t.Skip("Skipping integration test in short mode - requires PDS") 32 } 33 34 // Check if PDS is running 35 pdsURL := "http://localhost:3001" 36 healthResp, err := http.Get(pdsURL + "/xrpc/_health") 37 if err != nil { 38 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err) 39 } 40 defer func() { 41 if closeErr := healthResp.Body.Close(); closeErr != nil { 42 t.Logf("Failed to close health response: %v", closeErr) 43 } 44 }() 45 46 // Setup test database 47 db := setupTestDB(t) 48 defer func() { 49 if err := db.Close(); err != nil { 50 t.Logf("Failed to close database: %v", err) 51 } 52 }() 53 54 ctx := context.Background() 55 repo := postgres.NewCommunityRepository(db) 56 57 t.Run("creates community with real PDS provisioning", func(t *testing.T) { 58 // Create provisioner and service (production code path) 59 // Use coves.social domain (configured in PDS_SERVICE_HANDLE_DOMAINS as .community.coves.social) 60 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 61 service := communities.NewCommunityService( 62 repo, 63 pdsURL, 64 "did:web:coves.social", 65 "coves.social", 66 provisioner, 67 ) 68 69 // Generate unique community name (keep short for DNS label limit) 70 // Must start with letter, can contain alphanumeric and hyphens 71 uniqueName := fmt.Sprintf("svc%d", time.Now().UnixNano()%1000000) 72 73 // Create community via service (FULL PRODUCTION CODE PATH) 74 t.Logf("Creating community via service.CreateCommunity()...") 75 community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 76 Name: uniqueName, 77 DisplayName: "Test Community", 78 Description: "Integration test community with real PDS", 79 Visibility: "public", 80 CreatedByDID: "did:plc:testuser123", 81 HostedByDID: "did:web:coves.social", 82 AllowExternalDiscovery: true, 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.community.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 if err != nil { 329 t.Fatalf("Failed to create community: %v", err) 330 } 331 332 t.Logf("✅ Community created: %s", community.DID) 333 334 // Now update it 335 newDisplayName := "Updated Display Name" 336 newDescription := "Updated description via V2 write-forward" 337 newVisibility := "unlisted" 338 339 t.Logf("Updating community via service.UpdateCommunity()...") 340 updated, err := service.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 341 CommunityDID: community.DID, 342 UpdatedByDID: creatorDID, // Same as creator - should be authorized 343 DisplayName: &newDisplayName, 344 Description: &newDescription, 345 Visibility: &newVisibility, 346 AllowExternalDiscovery: nil, // Don't change 347 }) 348 if err != nil { 349 t.Fatalf("Failed to update community: %v", err) 350 } 351 352 t.Logf("✅ Community updated via PDS") 353 354 // Verify updates were applied 355 if updated.DisplayName != newDisplayName { 356 t.Errorf("Expected display name %s, got %s", newDisplayName, updated.DisplayName) 357 } 358 if updated.Description != newDescription { 359 t.Errorf("Expected description %s, got %s", newDescription, updated.Description) 360 } 361 if updated.Visibility != newVisibility { 362 t.Errorf("Expected visibility %s, got %s", newVisibility, updated.Visibility) 363 } 364 365 t.Logf("✅ Updates applied correctly") 366 367 // Verify record URI still points to community's own repo with rkey "self" 368 expectedURIPrefix := fmt.Sprintf("at://%s/social.coves.community.profile/self", community.DID) 369 if updated.RecordURI != expectedURIPrefix { 370 t.Errorf("Expected record URI %s, got %s", expectedURIPrefix, updated.RecordURI) 371 } 372 373 t.Logf("✅ Record URI correct (uses community's repo)") 374 375 // Verify record CID changed (new version) 376 if updated.RecordCID == community.RecordCID { 377 t.Error("Expected record CID to change after update") 378 } 379 380 t.Logf("✅ Record CID updated (new version)") 381 }) 382 383 t.Run("rejects unauthorized updates", func(t *testing.T) { 384 // Create a community 385 uniqueName := fmt.Sprintf("auth%d", time.Now().UnixNano()%1000000) 386 creatorDID := "did:plc:creator123" 387 388 community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 389 Name: uniqueName, 390 DisplayName: "Auth Test Community", 391 Visibility: "public", 392 CreatedByDID: creatorDID, 393 HostedByDID: "did:web:coves.social", 394 AllowExternalDiscovery: true, 395 }) 396 if err != nil { 397 t.Fatalf("Failed to create community: %v", err) 398 } 399 400 // Try to update as different user 401 differentUserDID := "did:plc:nottheowner" 402 newDisplayName := "Hacked Display Name" 403 404 _, err = service.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 405 CommunityDID: community.DID, 406 UpdatedByDID: differentUserDID, // NOT the creator 407 DisplayName: &newDisplayName, 408 }) 409 410 if err == nil { 411 t.Error("Expected authorization error for non-creator update") 412 } 413 414 if !strings.Contains(strings.ToLower(err.Error()), "unauthorized") { 415 t.Errorf("Expected 'unauthorized' error, got: %v", err) 416 } 417 418 t.Logf("✅ Unauthorized updates rejected") 419 }) 420 421 t.Run("handles missing PDS credentials", func(t *testing.T) { 422 // Create a community manually in DB without PDS credentials 423 // (simulating a federated community indexed from another instance) 424 uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano()) 425 communityDID := generateTestDID(uniqueSuffix) 426 427 federatedCommunity := &communities.Community{ 428 DID: communityDID, 429 Handle: fmt.Sprintf("federated-%s.external.social", uniqueSuffix), 430 Name: "federated-test", 431 OwnerDID: communityDID, 432 CreatedByDID: "did:plc:externaluser", 433 HostedByDID: "did:web:external.social", 434 Visibility: "public", 435 // No PDS credentials - this is a federated community 436 CreatedAt: time.Now(), 437 UpdatedAt: time.Now(), 438 } 439 440 _, err := repo.Create(ctx, federatedCommunity) 441 if err != nil { 442 t.Fatalf("Failed to create federated community: %v", err) 443 } 444 445 // Try to update it - should fail because we don't have credentials 446 newDisplayName := "Cannot Update This" 447 _, err = service.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 448 CommunityDID: communityDID, 449 UpdatedByDID: "did:plc:externaluser", 450 DisplayName: &newDisplayName, 451 }) 452 453 if err == nil { 454 t.Error("Expected error when updating community without PDS credentials") 455 } 456 457 if !strings.Contains(err.Error(), "missing PDS credentials") { 458 t.Logf("Error message: %v", err) 459 } 460 461 t.Logf("✅ Missing credentials handled gracefully") 462 }) 463} 464 465// TestPasswordAuthentication verifies that generated passwords work for PDS authentication 466// This is CRITICAL for P0: passwords must be recoverable for session renewal 467func TestPasswordAuthentication(t *testing.T) { 468 if testing.Short() { 469 t.Skip("Skipping integration test in short mode - requires PDS") 470 } 471 472 // Check if PDS is running 473 pdsURL := "http://localhost:3001" 474 healthResp, err := http.Get(pdsURL + "/xrpc/_health") 475 if err != nil { 476 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err) 477 } 478 defer func() { 479 if closeErr := healthResp.Body.Close(); closeErr != nil { 480 t.Logf("Failed to close health response: %v", closeErr) 481 } 482 }() 483 484 // Setup test database 485 db := setupTestDB(t) 486 defer func() { 487 if err := db.Close(); err != nil { 488 t.Logf("Failed to close database: %v", err) 489 } 490 }() 491 492 ctx := context.Background() 493 repo := postgres.NewCommunityRepository(db) 494 495 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 496 service := communities.NewCommunityService( 497 repo, 498 pdsURL, 499 "did:web:coves.social", 500 "coves.social", 501 provisioner, 502 ) 503 504 t.Run("generated password works for session creation", func(t *testing.T) { 505 // Create a community with PDS-generated password 506 uniqueName := fmt.Sprintf("pwd%d", time.Now().UnixNano()%1000000) 507 508 t.Logf("Creating community with generated password...") 509 community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 510 Name: uniqueName, 511 DisplayName: "Password Auth Test", 512 Visibility: "public", 513 CreatedByDID: "did:plc:testuser", 514 HostedByDID: "did:web:coves.social", 515 AllowExternalDiscovery: true, 516 }) 517 if err != nil { 518 t.Fatalf("Failed to create community: %v", err) 519 } 520 521 t.Logf("✅ Community created with password: %d chars", len(community.PDSPassword)) 522 523 // Retrieve from DB to get decrypted password 524 retrieved, err := repo.GetByDID(ctx, community.DID) 525 if err != nil { 526 t.Fatalf("Failed to retrieve community: %v", err) 527 } 528 529 t.Logf("✅ Password retrieved from DB (decrypted): %d chars", len(retrieved.PDSPassword)) 530 531 // Now try to authenticate with the password via com.atproto.server.createSession 532 // This simulates what we'd do for token renewal 533 sessionPayload := map[string]interface{}{ 534 "identifier": retrieved.Handle, // Use handle for login 535 "password": retrieved.PDSPassword, 536 } 537 538 payloadBytes, err := json.Marshal(sessionPayload) 539 if err != nil { 540 t.Fatalf("Failed to marshal session payload: %v", err) 541 } 542 543 sessionReq, err := http.NewRequestWithContext(ctx, "POST", 544 pdsURL+"/xrpc/com.atproto.server.createSession", 545 bytes.NewReader(payloadBytes)) 546 if err != nil { 547 t.Fatalf("Failed to create session request: %v", err) 548 } 549 sessionReq.Header.Set("Content-Type", "application/json") 550 551 client := &http.Client{Timeout: 10 * time.Second} 552 resp, err := client.Do(sessionReq) 553 if err != nil { 554 t.Fatalf("Failed to create session: %v", err) 555 } 556 defer func() { 557 if closeErr := resp.Body.Close(); closeErr != nil { 558 t.Logf("Failed to close response body: %v", closeErr) 559 } 560 }() 561 562 body, err := io.ReadAll(resp.Body) 563 if err != nil { 564 t.Fatalf("Failed to read response body: %v", err) 565 } 566 567 if resp.StatusCode != http.StatusOK { 568 t.Fatalf("Session creation failed with status %d: %s", resp.StatusCode, string(body)) 569 } 570 571 // Verify we got new tokens 572 var sessionResp struct { 573 AccessJwt string `json:"accessJwt"` 574 RefreshJwt string `json:"refreshJwt"` 575 DID string `json:"did"` 576 } 577 578 if err := json.Unmarshal(body, &sessionResp); err != nil { 579 t.Fatalf("Failed to parse session response: %v", err) 580 } 581 582 if sessionResp.AccessJwt == "" { 583 t.Error("Expected new access token from session") 584 } 585 if sessionResp.RefreshJwt == "" { 586 t.Error("Expected new refresh token from session") 587 } 588 if sessionResp.DID != community.DID { 589 t.Errorf("Expected session DID %s, got %s", community.DID, sessionResp.DID) 590 } 591 592 t.Logf("✅ Password authentication successful!") 593 t.Logf(" - New access token: %d chars", len(sessionResp.AccessJwt)) 594 t.Logf(" - New refresh token: %d chars", len(sessionResp.RefreshJwt)) 595 t.Logf(" - Session DID: %s", sessionResp.DID) 596 597 t.Logf("✅ CRITICAL TEST PASSED: Password encryption enables session renewal") 598 }) 599}