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}