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}