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}