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}