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