A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "Coves/internal/atproto/jetstream"
5 "Coves/internal/atproto/pds"
6 "Coves/internal/atproto/utils"
7 "Coves/internal/core/comments"
8 "Coves/internal/db/postgres"
9 "context"
10 "database/sql"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "io"
15 "net/http"
16 "os"
17 "testing"
18 "time"
19
20 oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
21 "github.com/bluesky-social/indigo/atproto/syntax"
22 _ "github.com/lib/pq"
23 "github.com/pressly/goose/v3"
24)
25
26// TestCommentWrite_CreateTopLevelComment tests creating a comment on a post via E2E flow
27func TestCommentWrite_CreateTopLevelComment(t *testing.T) {
28 // Skip in short mode since this requires real PDS
29 if testing.Short() {
30 t.Skip("Skipping E2E test in short mode")
31 }
32
33 // Setup test database
34 dbURL := os.Getenv("TEST_DATABASE_URL")
35 if dbURL == "" {
36 dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
37 }
38
39 db, err := sql.Open("postgres", dbURL)
40 if err != nil {
41 t.Fatalf("Failed to connect to test database: %v", err)
42 }
43 defer func() {
44 if closeErr := db.Close(); closeErr != nil {
45 t.Logf("Failed to close database: %v", closeErr)
46 }
47 }()
48
49 // Run migrations
50 if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
51 t.Fatalf("Failed to set goose dialect: %v", dialectErr)
52 }
53 if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
54 t.Fatalf("Failed to run migrations: %v", migrateErr)
55 }
56
57 // Check if PDS is running
58 pdsURL := getTestPDSURL()
59 healthResp, err := http.Get(pdsURL + "/xrpc/_health")
60 if err != nil {
61 t.Skipf("PDS not running at %s: %v", pdsURL, err)
62 }
63 func() {
64 if closeErr := healthResp.Body.Close(); closeErr != nil {
65 t.Logf("Failed to close health response: %v", closeErr)
66 }
67 }()
68
69 ctx := context.Background()
70
71 // Setup repositories
72 commentRepo := postgres.NewCommentRepository(db)
73 postRepo := postgres.NewPostRepository(db)
74
75 // Setup service with password-based PDS client factory for E2E testing
76 // CommentPDSClientFactory creates a PDS client for comment operations
77 commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
78 if session.AccessToken == "" {
79 return nil, fmt.Errorf("session has no access token")
80 }
81 if session.HostURL == "" {
82 return nil, fmt.Errorf("session has no host URL")
83 }
84
85 return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
86 }
87
88 commentService := comments.NewCommentServiceWithPDSFactory(
89 commentRepo,
90 nil, // userRepo not needed for write ops
91 postRepo,
92 nil, // communityRepo not needed for write ops
93 nil, // logger
94 commentPDSFactory,
95 )
96
97 // Create test user on PDS
98 testUserHandle := fmt.Sprintf("commenter-%d.local.coves.dev", time.Now().Unix())
99 testUserEmail := fmt.Sprintf("commenter-%d@test.local", time.Now().Unix())
100 testUserPassword := "test-password-123"
101
102 t.Logf("Creating test user on PDS: %s", testUserHandle)
103 pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
104 if err != nil {
105 t.Fatalf("Failed to create test user on PDS: %v", err)
106 }
107 t.Logf("Test user created: DID=%s", userDID)
108
109 // Index user in AppView
110 testUser := createTestUser(t, db, testUserHandle, userDID)
111
112 // Create test community and post to comment on
113 testCommunityDID, err := createFeedTestCommunity(db, ctx, "test-community", "owner.test")
114 if err != nil {
115 t.Fatalf("Failed to create test community: %v", err)
116 }
117
118 postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post", 0, time.Now())
119 postCID := "bafypost123"
120
121 // Create mock OAuth session for service layer
122 mockStore := NewMockOAuthStore()
123 mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
124
125 // ====================================================================================
126 // TEST: Create top-level comment on post
127 // ====================================================================================
128 t.Logf("\n📝 Creating top-level comment via service...")
129
130 commentReq := comments.CreateCommentRequest{
131 Reply: comments.ReplyRef{
132 Root: comments.StrongRef{
133 URI: postURI,
134 CID: postCID,
135 },
136 Parent: comments.StrongRef{
137 URI: postURI,
138 CID: postCID,
139 },
140 },
141 Content: "This is a test comment on the post",
142 Langs: []string{"en"},
143 }
144
145 // Get session from store
146 parsedDID, _ := parseTestDID(userDID)
147 session, err := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
148 if err != nil {
149 t.Fatalf("Failed to get session: %v", err)
150 }
151
152 commentResp, err := commentService.CreateComment(ctx, session, commentReq)
153 if err != nil {
154 t.Fatalf("Failed to create comment: %v", err)
155 }
156
157 t.Logf("✅ Comment created:")
158 t.Logf(" URI: %s", commentResp.URI)
159 t.Logf(" CID: %s", commentResp.CID)
160
161 // Verify comment record was written to PDS
162 t.Logf("\n🔍 Verifying comment record on PDS...")
163 rkey := utils.ExtractRKeyFromURI(commentResp.URI)
164 collection := "social.coves.community.comment"
165
166 pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
167 pdsURL, userDID, collection, rkey))
168 if pdsErr != nil {
169 t.Fatalf("Failed to fetch comment record from PDS: %v", pdsErr)
170 }
171 defer func() {
172 if closeErr := pdsResp.Body.Close(); closeErr != nil {
173 t.Logf("Failed to close PDS response: %v", closeErr)
174 }
175 }()
176
177 if pdsResp.StatusCode != http.StatusOK {
178 body, _ := io.ReadAll(pdsResp.Body)
179 t.Fatalf("Comment record not found on PDS: status %d, body: %s", pdsResp.StatusCode, string(body))
180 }
181
182 var pdsRecord struct {
183 Value map[string]interface{} `json:"value"`
184 CID string `json:"cid"`
185 }
186 if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil {
187 t.Fatalf("Failed to decode PDS record: %v", decodeErr)
188 }
189
190 t.Logf("✅ Comment record found on PDS:")
191 t.Logf(" CID: %s", pdsRecord.CID)
192 t.Logf(" Content: %v", pdsRecord.Value["content"])
193
194 // Verify content
195 if pdsRecord.Value["content"] != "This is a test comment on the post" {
196 t.Errorf("Expected content 'This is a test comment on the post', got %v", pdsRecord.Value["content"])
197 }
198
199 // Simulate Jetstream consumer indexing the comment
200 t.Logf("\n🔄 Simulating Jetstream consumer indexing comment...")
201 commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
202
203 commentEvent := jetstream.JetstreamEvent{
204 Did: userDID,
205 TimeUS: time.Now().UnixMicro(),
206 Kind: "commit",
207 Commit: &jetstream.CommitEvent{
208 Rev: "test-comment-rev",
209 Operation: "create",
210 Collection: "social.coves.community.comment",
211 RKey: rkey,
212 CID: pdsRecord.CID,
213 Record: map[string]interface{}{
214 "$type": "social.coves.community.comment",
215 "reply": map[string]interface{}{
216 "root": map[string]interface{}{
217 "uri": postURI,
218 "cid": postCID,
219 },
220 "parent": map[string]interface{}{
221 "uri": postURI,
222 "cid": postCID,
223 },
224 },
225 "content": "This is a test comment on the post",
226 "createdAt": time.Now().Format(time.RFC3339),
227 },
228 },
229 }
230
231 if handleErr := commentConsumer.HandleEvent(ctx, &commentEvent); handleErr != nil {
232 t.Fatalf("Failed to handle comment event: %v", handleErr)
233 }
234
235 // Verify comment was indexed in AppView
236 t.Logf("\n🔍 Verifying comment indexed in AppView...")
237 indexedComment, err := commentRepo.GetByURI(ctx, commentResp.URI)
238 if err != nil {
239 t.Fatalf("Comment not indexed in AppView: %v", err)
240 }
241
242 t.Logf("✅ Comment indexed in AppView:")
243 t.Logf(" CommenterDID: %s", indexedComment.CommenterDID)
244 t.Logf(" Content: %s", indexedComment.Content)
245 t.Logf(" RootURI: %s", indexedComment.RootURI)
246 t.Logf(" ParentURI: %s", indexedComment.ParentURI)
247
248 // Verify comment details
249 if indexedComment.CommenterDID != userDID {
250 t.Errorf("Expected commenter_did %s, got %s", userDID, indexedComment.CommenterDID)
251 }
252 if indexedComment.RootURI != postURI {
253 t.Errorf("Expected root_uri %s, got %s", postURI, indexedComment.RootURI)
254 }
255 if indexedComment.ParentURI != postURI {
256 t.Errorf("Expected parent_uri %s, got %s", postURI, indexedComment.ParentURI)
257 }
258 if indexedComment.Content != "This is a test comment on the post" {
259 t.Errorf("Expected content 'This is a test comment on the post', got %s", indexedComment.Content)
260 }
261
262 // Verify post comment count updated
263 t.Logf("\n🔍 Verifying post comment count updated...")
264 updatedPost, err := postRepo.GetByURI(ctx, postURI)
265 if err != nil {
266 t.Fatalf("Failed to get updated post: %v", err)
267 }
268
269 if updatedPost.CommentCount != 1 {
270 t.Errorf("Expected comment_count = 1, got %d", updatedPost.CommentCount)
271 }
272
273 t.Logf("✅ TRUE E2E COMMENT CREATE FLOW COMPLETE:")
274 t.Logf(" Client → Service → PDS Write → Jetstream → Consumer → AppView ✓")
275 t.Logf(" ✓ Comment written to PDS")
276 t.Logf(" ✓ Comment indexed in AppView")
277 t.Logf(" ✓ Post comment count updated")
278}
279
280// TestCommentWrite_CreateNestedReply tests creating a reply to another comment
281func TestCommentWrite_CreateNestedReply(t *testing.T) {
282 if testing.Short() {
283 t.Skip("Skipping E2E test in short mode")
284 }
285
286 db := setupTestDB(t)
287 defer func() { _ = db.Close() }()
288
289 ctx := context.Background()
290 pdsURL := getTestPDSURL()
291
292 // Setup repositories and service
293 commentRepo := postgres.NewCommentRepository(db)
294 postRepo := postgres.NewPostRepository(db)
295
296 // CommentPDSClientFactory creates a PDS client for comment operations
297 commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
298 if session.AccessToken == "" {
299 return nil, fmt.Errorf("session has no access token")
300 }
301 if session.HostURL == "" {
302 return nil, fmt.Errorf("session has no host URL")
303 }
304
305 return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
306 }
307
308 commentService := comments.NewCommentServiceWithPDSFactory(
309 commentRepo,
310 nil,
311 postRepo,
312 nil,
313 nil,
314 commentPDSFactory,
315 )
316
317 // Create test user
318 testUserHandle := fmt.Sprintf("replier-%d.local.coves.dev", time.Now().Unix())
319 testUserEmail := fmt.Sprintf("replier-%d@test.local", time.Now().Unix())
320 testUserPassword := "test-password-123"
321
322 pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
323 if err != nil {
324 t.Skipf("PDS not available: %v", err)
325 }
326
327 testUser := createTestUser(t, db, testUserHandle, userDID)
328
329 // Create test post and parent comment
330 testCommunityDID, _ := createFeedTestCommunity(db, ctx, "reply-community", "owner.test")
331 postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post", 0, time.Now())
332 postCID := "bafypost456"
333
334 // Create parent comment directly in DB (simulating already-indexed comment)
335 parentCommentURI := fmt.Sprintf("at://%s/social.coves.community.comment/parent123", userDID)
336 parentCommentCID := "bafyparent123"
337 _, err = db.ExecContext(ctx, `
338 INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at)
339 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
340 `, parentCommentURI, parentCommentCID, "parent123", userDID, postURI, postCID, postURI, postCID, "Parent comment")
341 if err != nil {
342 t.Fatalf("Failed to create parent comment: %v", err)
343 }
344
345 // Setup OAuth
346 mockStore := NewMockOAuthStore()
347 mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
348
349 // Create nested reply
350 t.Logf("\n📝 Creating nested reply...")
351 replyReq := comments.CreateCommentRequest{
352 Reply: comments.ReplyRef{
353 Root: comments.StrongRef{
354 URI: postURI,
355 CID: postCID,
356 },
357 Parent: comments.StrongRef{
358 URI: parentCommentURI,
359 CID: parentCommentCID,
360 },
361 },
362 Content: "This is a reply to the parent comment",
363 Langs: []string{"en"},
364 }
365
366 parsedDID, _ := parseTestDID(userDID)
367 session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
368
369 replyResp, err := commentService.CreateComment(ctx, session, replyReq)
370 if err != nil {
371 t.Fatalf("Failed to create reply: %v", err)
372 }
373
374 t.Logf("✅ Reply created: %s", replyResp.URI)
375
376 // Simulate Jetstream indexing
377 rkey := utils.ExtractRKeyFromURI(replyResp.URI)
378 commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
379
380 replyEvent := jetstream.JetstreamEvent{
381 Did: userDID,
382 TimeUS: time.Now().UnixMicro(),
383 Kind: "commit",
384 Commit: &jetstream.CommitEvent{
385 Rev: "test-reply-rev",
386 Operation: "create",
387 Collection: "social.coves.community.comment",
388 RKey: rkey,
389 CID: replyResp.CID,
390 Record: map[string]interface{}{
391 "$type": "social.coves.community.comment",
392 "reply": map[string]interface{}{
393 "root": map[string]interface{}{
394 "uri": postURI,
395 "cid": postCID,
396 },
397 "parent": map[string]interface{}{
398 "uri": parentCommentURI,
399 "cid": parentCommentCID,
400 },
401 },
402 "content": "This is a reply to the parent comment",
403 "createdAt": time.Now().Format(time.RFC3339),
404 },
405 },
406 }
407
408 if handleErr := commentConsumer.HandleEvent(ctx, &replyEvent); handleErr != nil {
409 t.Fatalf("Failed to handle reply event: %v", handleErr)
410 }
411
412 // Verify reply was indexed with correct parent
413 indexedReply, err := commentRepo.GetByURI(ctx, replyResp.URI)
414 if err != nil {
415 t.Fatalf("Reply not indexed: %v", err)
416 }
417
418 if indexedReply.RootURI != postURI {
419 t.Errorf("Expected root_uri %s, got %s", postURI, indexedReply.RootURI)
420 }
421 if indexedReply.ParentURI != parentCommentURI {
422 t.Errorf("Expected parent_uri %s, got %s", parentCommentURI, indexedReply.ParentURI)
423 }
424
425 t.Logf("✅ NESTED REPLY FLOW COMPLETE:")
426 t.Logf(" ✓ Reply created with correct parent reference")
427 t.Logf(" ✓ Reply indexed in AppView")
428}
429
430// TestCommentWrite_UpdateComment tests updating an existing comment
431func TestCommentWrite_UpdateComment(t *testing.T) {
432 if testing.Short() {
433 t.Skip("Skipping E2E test in short mode")
434 }
435
436 db := setupTestDB(t)
437 defer func() { _ = db.Close() }()
438
439 ctx := context.Background()
440 pdsURL := getTestPDSURL()
441
442 // Setup repositories and service
443 commentRepo := postgres.NewCommentRepository(db)
444
445 // CommentPDSClientFactory creates a PDS client for comment operations
446 commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
447 if session.AccessToken == "" {
448 return nil, fmt.Errorf("session has no access token")
449 }
450 if session.HostURL == "" {
451 return nil, fmt.Errorf("session has no host URL")
452 }
453
454 return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
455 }
456
457 commentService := comments.NewCommentServiceWithPDSFactory(
458 commentRepo,
459 nil,
460 nil,
461 nil,
462 nil,
463 commentPDSFactory,
464 )
465
466 // Create test user
467 testUserHandle := fmt.Sprintf("updater-%d.local.coves.dev", time.Now().Unix())
468 testUserEmail := fmt.Sprintf("updater-%d@test.local", time.Now().Unix())
469 testUserPassword := "test-password-123"
470
471 pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
472 if err != nil {
473 t.Skipf("PDS not available: %v", err)
474 }
475
476 // Setup OAuth
477 mockStore := NewMockOAuthStore()
478 mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
479
480 parsedDID, _ := parseTestDID(userDID)
481 session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
482
483 // First, create a comment to update
484 t.Logf("\n📝 Creating initial comment...")
485 createReq := comments.CreateCommentRequest{
486 Reply: comments.ReplyRef{
487 Root: comments.StrongRef{
488 URI: "at://did:plc:test/social.coves.community.post/test123",
489 CID: "bafypost",
490 },
491 Parent: comments.StrongRef{
492 URI: "at://did:plc:test/social.coves.community.post/test123",
493 CID: "bafypost",
494 },
495 },
496 Content: "Original content",
497 Langs: []string{"en"},
498 }
499
500 createResp, err := commentService.CreateComment(ctx, session, createReq)
501 if err != nil {
502 t.Fatalf("Failed to create comment: %v", err)
503 }
504
505 t.Logf("✅ Initial comment created: %s", createResp.URI)
506
507 // Now update the comment
508 t.Logf("\n📝 Updating comment...")
509 updateReq := comments.UpdateCommentRequest{
510 URI: createResp.URI,
511 Content: "Updated content - this has been edited",
512 }
513
514 updateResp, err := commentService.UpdateComment(ctx, session, updateReq)
515 if err != nil {
516 t.Fatalf("Failed to update comment: %v", err)
517 }
518
519 t.Logf("✅ Comment updated:")
520 t.Logf(" URI: %s", updateResp.URI)
521 t.Logf(" New CID: %s", updateResp.CID)
522
523 // Verify the update on PDS
524 rkey := utils.ExtractRKeyFromURI(updateResp.URI)
525 pdsResp, _ := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",
526 pdsURL, userDID, rkey))
527 defer pdsResp.Body.Close()
528
529 var pdsRecord struct {
530 Value map[string]interface{} `json:"value"`
531 CID string `json:"cid"`
532 }
533 json.NewDecoder(pdsResp.Body).Decode(&pdsRecord)
534
535 if pdsRecord.Value["content"] != "Updated content - this has been edited" {
536 t.Errorf("Expected updated content, got %v", pdsRecord.Value["content"])
537 }
538
539 t.Logf("✅ UPDATE FLOW COMPLETE:")
540 t.Logf(" ✓ Comment updated on PDS")
541 t.Logf(" ✓ New CID generated")
542 t.Logf(" ✓ Content verified")
543}
544
545// TestCommentWrite_DeleteComment tests deleting a comment
546func TestCommentWrite_DeleteComment(t *testing.T) {
547 if testing.Short() {
548 t.Skip("Skipping E2E test in short mode")
549 }
550
551 db := setupTestDB(t)
552 defer func() { _ = db.Close() }()
553
554 ctx := context.Background()
555 pdsURL := getTestPDSURL()
556
557 // Setup repositories and service
558 commentRepo := postgres.NewCommentRepository(db)
559
560 // CommentPDSClientFactory creates a PDS client for comment operations
561 commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
562 if session.AccessToken == "" {
563 return nil, fmt.Errorf("session has no access token")
564 }
565 if session.HostURL == "" {
566 return nil, fmt.Errorf("session has no host URL")
567 }
568
569 return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
570 }
571
572 commentService := comments.NewCommentServiceWithPDSFactory(
573 commentRepo,
574 nil,
575 nil,
576 nil,
577 nil,
578 commentPDSFactory,
579 )
580
581 // Create test user
582 testUserHandle := fmt.Sprintf("deleter-%d.local.coves.dev", time.Now().Unix())
583 testUserEmail := fmt.Sprintf("deleter-%d@test.local", time.Now().Unix())
584 testUserPassword := "test-password-123"
585
586 pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
587 if err != nil {
588 t.Skipf("PDS not available: %v", err)
589 }
590
591 // Setup OAuth
592 mockStore := NewMockOAuthStore()
593 mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
594
595 parsedDID, _ := parseTestDID(userDID)
596 session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
597
598 // First, create a comment to delete
599 t.Logf("\n📝 Creating comment to delete...")
600 createReq := comments.CreateCommentRequest{
601 Reply: comments.ReplyRef{
602 Root: comments.StrongRef{
603 URI: "at://did:plc:test/social.coves.community.post/test123",
604 CID: "bafypost",
605 },
606 Parent: comments.StrongRef{
607 URI: "at://did:plc:test/social.coves.community.post/test123",
608 CID: "bafypost",
609 },
610 },
611 Content: "This comment will be deleted",
612 Langs: []string{"en"},
613 }
614
615 createResp, err := commentService.CreateComment(ctx, session, createReq)
616 if err != nil {
617 t.Fatalf("Failed to create comment: %v", err)
618 }
619
620 t.Logf("✅ Comment created: %s", createResp.URI)
621
622 // Now delete the comment
623 t.Logf("\n📝 Deleting comment...")
624 deleteReq := comments.DeleteCommentRequest{
625 URI: createResp.URI,
626 }
627
628 err = commentService.DeleteComment(ctx, session, deleteReq)
629 if err != nil {
630 t.Fatalf("Failed to delete comment: %v", err)
631 }
632
633 t.Logf("✅ Comment deleted")
634
635 // Verify deletion on PDS
636 rkey := utils.ExtractRKeyFromURI(createResp.URI)
637 pdsResp, _ := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",
638 pdsURL, userDID, rkey))
639 defer pdsResp.Body.Close()
640
641 if pdsResp.StatusCode != http.StatusBadRequest && pdsResp.StatusCode != http.StatusNotFound {
642 t.Errorf("Expected 400 or 404 for deleted comment, got %d", pdsResp.StatusCode)
643 }
644
645 t.Logf("✅ DELETE FLOW COMPLETE:")
646 t.Logf(" ✓ Comment deleted from PDS")
647 t.Logf(" ✓ Record no longer accessible")
648}
649
650// TestCommentWrite_CannotUpdateOthersComment tests authorization for updates
651func TestCommentWrite_CannotUpdateOthersComment(t *testing.T) {
652 if testing.Short() {
653 t.Skip("Skipping E2E test in short mode")
654 }
655
656 db := setupTestDB(t)
657 defer func() { _ = db.Close() }()
658
659 ctx := context.Background()
660 pdsURL := getTestPDSURL()
661
662 // CommentPDSClientFactory creates a PDS client for comment operations
663 commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
664 if session.AccessToken == "" {
665 return nil, fmt.Errorf("session has no access token")
666 }
667 if session.HostURL == "" {
668 return nil, fmt.Errorf("session has no host URL")
669 }
670
671 return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
672 }
673
674 // Setup service
675 commentService := comments.NewCommentServiceWithPDSFactory(
676 nil,
677 nil,
678 nil,
679 nil,
680 nil,
681 commentPDSFactory,
682 )
683
684 // Create first user (comment owner)
685 ownerHandle := fmt.Sprintf("owner-%d.local.coves.dev", time.Now().Unix())
686 ownerEmail := fmt.Sprintf("owner-%d@test.local", time.Now().Unix())
687 _, ownerDID, err := createPDSAccount(pdsURL, ownerHandle, ownerEmail, "password123")
688 if err != nil {
689 t.Skipf("PDS not available: %v", err)
690 }
691
692 // Create second user (attacker)
693 attackerHandle := fmt.Sprintf("attacker-%d.local.coves.dev", time.Now().Unix())
694 attackerEmail := fmt.Sprintf("attacker-%d@test.local", time.Now().Unix())
695 attackerToken, attackerDID, err := createPDSAccount(pdsURL, attackerHandle, attackerEmail, "password123")
696 if err != nil {
697 t.Skipf("PDS not available: %v", err)
698 }
699
700 // Setup OAuth for attacker
701 mockStore := NewMockOAuthStore()
702 mockStore.AddSessionWithPDS(attackerDID, "session-"+attackerDID, attackerToken, pdsURL)
703
704 parsedDID, _ := parseTestDID(attackerDID)
705 session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+attackerDID)
706
707 // Try to update comment owned by different user
708 t.Logf("\n🚨 Attempting to update another user's comment...")
709 updateReq := comments.UpdateCommentRequest{
710 URI: fmt.Sprintf("at://%s/social.coves.community.comment/test123", ownerDID),
711 Content: "Malicious update attempt",
712 }
713
714 _, err = commentService.UpdateComment(ctx, session, updateReq)
715
716 // Verify authorization error
717 if err == nil {
718 t.Fatal("Expected authorization error, got nil")
719 }
720 if !errors.Is(err, comments.ErrNotAuthorized) {
721 t.Errorf("Expected ErrNotAuthorized, got: %v", err)
722 }
723
724 t.Logf("✅ AUTHORIZATION CHECK PASSED:")
725 t.Logf(" ✓ User cannot update others' comments")
726}
727
728// TestCommentWrite_CannotDeleteOthersComment tests authorization for deletes
729func TestCommentWrite_CannotDeleteOthersComment(t *testing.T) {
730 if testing.Short() {
731 t.Skip("Skipping E2E test in short mode")
732 }
733
734 db := setupTestDB(t)
735 defer func() { _ = db.Close() }()
736
737 ctx := context.Background()
738 pdsURL := getTestPDSURL()
739
740 // CommentPDSClientFactory creates a PDS client for comment operations
741 commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
742 if session.AccessToken == "" {
743 return nil, fmt.Errorf("session has no access token")
744 }
745 if session.HostURL == "" {
746 return nil, fmt.Errorf("session has no host URL")
747 }
748
749 return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
750 }
751
752 // Setup service
753 commentService := comments.NewCommentServiceWithPDSFactory(
754 nil,
755 nil,
756 nil,
757 nil,
758 nil,
759 commentPDSFactory,
760 )
761
762 // Create first user (comment owner)
763 ownerHandle := fmt.Sprintf("owner-%d.local.coves.dev", time.Now().Unix())
764 ownerEmail := fmt.Sprintf("owner-%d@test.local", time.Now().Unix())
765 _, ownerDID, err := createPDSAccount(pdsURL, ownerHandle, ownerEmail, "password123")
766 if err != nil {
767 t.Skipf("PDS not available: %v", err)
768 }
769
770 // Create second user (attacker)
771 attackerHandle := fmt.Sprintf("attacker-%d.local.coves.dev", time.Now().Unix())
772 attackerEmail := fmt.Sprintf("attacker-%d@test.local", time.Now().Unix())
773 attackerToken, attackerDID, err := createPDSAccount(pdsURL, attackerHandle, attackerEmail, "password123")
774 if err != nil {
775 t.Skipf("PDS not available: %v", err)
776 }
777
778 // Setup OAuth for attacker
779 mockStore := NewMockOAuthStore()
780 mockStore.AddSessionWithPDS(attackerDID, "session-"+attackerDID, attackerToken, pdsURL)
781
782 parsedDID, _ := parseTestDID(attackerDID)
783 session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+attackerDID)
784
785 // Try to delete comment owned by different user
786 t.Logf("\n🚨 Attempting to delete another user's comment...")
787 deleteReq := comments.DeleteCommentRequest{
788 URI: fmt.Sprintf("at://%s/social.coves.community.comment/test123", ownerDID),
789 }
790
791 err = commentService.DeleteComment(ctx, session, deleteReq)
792
793 // Verify authorization error
794 if err == nil {
795 t.Fatal("Expected authorization error, got nil")
796 }
797 if !errors.Is(err, comments.ErrNotAuthorized) {
798 t.Errorf("Expected ErrNotAuthorized, got: %v", err)
799 }
800
801 t.Logf("✅ AUTHORIZATION CHECK PASSED:")
802 t.Logf(" ✓ User cannot delete others' comments")
803}
804
805// Helper function to parse DID for testing
806func parseTestDID(did string) (syntax.DID, error) {
807 return syntax.ParseDID(did)
808}