diff --git a/tests/integration/aggregator_e2e_test.go b/tests/integration/aggregator_e2e_test.go index ed427e1..1a9f76f 100644 --- a/tests/integration/aggregator_e2e_test.go +++ b/tests/integration/aggregator_e2e_test.go @@ -71,7 +71,7 @@ func TestAggregator_E2E_WithJetstream(t *testing.T) { userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001") communityService := communities.NewCommunityService(communityRepo, "http://localhost:3001", "did:web:test.coves.social", "coves.social", nil) aggregatorService := aggregators.NewAggregatorService(aggregatorRepo, communityService) - postService := posts.NewPostService(postRepo, communityService, aggregatorService, "http://localhost:3001") + postService := posts.NewPostService(postRepo, communityService, aggregatorService, nil, nil, "http://localhost:3001") // Setup consumers aggregatorConsumer := jetstream.NewAggregatorEventConsumer(aggregatorRepo) diff --git a/tests/integration/feed_test.go b/tests/integration/feed_test.go index 2e18997..b554bae 100644 --- a/tests/integration/feed_test.go +++ b/tests/integration/feed_test.go @@ -699,3 +699,102 @@ func TestGetCommunityFeed_HotCursorPrecision(t *testing.T) { t.Logf("SUCCESS: All posts with similar hot ranks preserved (precision bug fixed)") } + +// TestGetCommunityFeed_BlobURLTransformation tests that blob refs are transformed to URLs +func TestGetCommunityFeed_BlobURLTransformation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + db := setupTestDB(t) + t.Cleanup(func() { _ = db.Close() }) + + // Setup services + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") + communityRepo := postgres.NewCommunityRepository(db) + communityService := communities.NewCommunityService( + communityRepo, + "http://localhost:3001", + "did:web:test.coves.social", + "test.coves.social", + nil, + ) + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) + handler := communityFeed.NewGetCommunityHandler(feedService) + + // Setup test data + ctx := context.Background() + testID := time.Now().UnixNano() + communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("blobtest-%d", testID), fmt.Sprintf("blobtest-%d.test", testID)) + require.NoError(t, err) + + // Create author user + authorDID := "did:plc:blobauthor" + _, _ = db.ExecContext(ctx, ` + INSERT INTO users (did, handle, pds_url, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (did) DO NOTHING + `, authorDID, "blobauthor.bsky.social", "https://bsky.social") + + // Create a post with an external embed containing a blob thumbnail + rkey := fmt.Sprintf("post-%d", time.Now().UnixNano()) + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", communityDID, rkey) + + embedJSON := `{ + "$type": "social.coves.embed.external", + "external": { + "uri": "https://example.com/article", + "title": "Example Article", + "description": "A test article", + "thumb": { + "$type": "blob", + "ref": { + "$link": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + }, + "mimeType": "image/jpeg", + "size": 52813 + } + } + }` + + _, err = db.ExecContext(ctx, ` + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, embed, created_at, score, upvote_count) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 10, 10) + `, uri, "bafytest", rkey, authorDID, communityDID, "Post with blob thumb", embedJSON) + require.NoError(t, err) + + // Request community feed + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10", communityDID), nil) + rec := httptest.NewRecorder() + handler.HandleGetCommunity(rec, req) + + // Assertions + assert.Equal(t, http.StatusOK, rec.Code) + + var response communityFeeds.FeedResponse + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + require.Len(t, response.Feed, 1, "Should have one post") + + // Verify blob ref was transformed to URL + feedPost := response.Feed[0] + require.NotNil(t, feedPost.Post.Embed, "Post should have embed") + + embedMap, ok := feedPost.Post.Embed.(map[string]interface{}) + require.True(t, ok, "Embed should be a map") + + assert.Equal(t, "social.coves.embed.external", embedMap["$type"], "Embed type should be external") + + external, ok := embedMap["external"].(map[string]interface{}) + require.True(t, ok, "External should be a map") + + // CRITICAL: Thumb should now be a URL string, not a blob object + thumbURL, ok := external["thumb"].(string) + require.True(t, ok, "Thumb should be a string URL after transformation") + + expectedURL := "http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:community-blobtest-" + fmt.Sprint(testID) + "&cid=bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + assert.Equal(t, expectedURL, thumbURL, "Thumb URL should match expected format") + + t.Logf("SUCCESS: Blob ref transformed to URL: %s", thumbURL) +} diff --git a/tests/integration/helpers.go b/tests/integration/helpers.go index 717b41c..7ac12ff 100644 --- a/tests/integration/helpers.go +++ b/tests/integration/helpers.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "testing" "time" @@ -18,6 +19,24 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// getTestPDSURL returns the PDS URL for testing from env var or default +func getTestPDSURL() string { + pdsURL := os.Getenv("PDS_URL") + if pdsURL == "" { + pdsURL = "http://localhost:3001" + } + return pdsURL +} + +// getTestInstanceDID returns the instance DID for testing from env var or default +func getTestInstanceDID() string { + instanceDID := os.Getenv("INSTANCE_DID") + if instanceDID == "" { + instanceDID = "did:web:test.coves.social" + } + return instanceDID +} + // createTestUser creates a test user in the database for use in integration tests // Returns the created user or fails the test func createTestUser(t *testing.T, db *sql.DB, handle, did string) *users.User { @@ -33,7 +52,7 @@ func createTestUser(t *testing.T, db *sql.DB, handle, did string) *users.User { ` user := &users.User{} - err := db.QueryRowContext(ctx, query, did, handle, "http://localhost:3001").Scan( + err := db.QueryRowContext(ctx, query, did, handle, getTestPDSURL()).Scan( &user.DID, &user.Handle, &user.PDSURL, @@ -105,7 +124,7 @@ func createSimpleTestJWT(userDID string) string { RegisteredClaims: jwt.RegisteredClaims{ Subject: userDID, Issuer: userDID, // Use DID as issuer for testing (valid per atProto) - Audience: jwt.ClaimStrings{"did:web:test.coves.social"}, + Audience: jwt.ClaimStrings{getTestInstanceDID()}, IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), }, @@ -237,13 +256,17 @@ func writePDSRecord(pdsURL, accessToken, repo, collection, rkey string, record i // createFeedTestCommunity creates a test community for feed tests // Returns the community DID or an error func createFeedTestCommunity(db *sql.DB, ctx context.Context, name, ownerHandle string) (string, error) { + // Get configuration from env vars + pdsURL := getTestPDSURL() + instanceDID := getTestInstanceDID() + // Create owner user first (directly insert to avoid service dependencies) ownerDID := fmt.Sprintf("did:plc:%s", ownerHandle) _, err := db.ExecContext(ctx, ` INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT (did) DO NOTHING - `, ownerDID, ownerHandle, "https://bsky.social") + `, ownerDID, ownerHandle, pdsURL) if err != nil { return "", err } @@ -251,10 +274,10 @@ func createFeedTestCommunity(db *sql.DB, ctx context.Context, name, ownerHandle // Create community communityDID := fmt.Sprintf("did:plc:community-%s", name) _, err = db.ExecContext(ctx, ` - INSERT INTO communities (did, name, owner_did, created_by_did, hosted_by_did, handle, created_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW()) + INSERT INTO communities (did, name, owner_did, created_by_did, hosted_by_did, handle, pds_url, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT (did) DO NOTHING - `, communityDID, name, ownerDID, ownerDID, "did:web:test.coves.social", fmt.Sprintf("%s.coves.social", name)) + `, communityDID, name, ownerDID, ownerDID, instanceDID, fmt.Sprintf("%s.coves.social", name), pdsURL) return communityDID, err } @@ -270,7 +293,7 @@ func createTestPost(t *testing.T, db *sql.DB, communityDID, authorDID, title str INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT (did) DO NOTHING - `, authorDID, fmt.Sprintf("%s.bsky.social", authorDID), "https://bsky.social") + `, authorDID, fmt.Sprintf("%s.bsky.social", authorDID), getTestPDSURL()) // Generate URI rkey := fmt.Sprintf("post-%d", time.Now().UnixNano()) diff --git a/tests/integration/post_e2e_test.go b/tests/integration/post_e2e_test.go index 33f8cc0..6d1ff1b 100644 --- a/tests/integration/post_e2e_test.go +++ b/tests/integration/post_e2e_test.go @@ -403,7 +403,7 @@ func TestPostCreation_E2E_LivePDS(t *testing.T) { provisioner, // ✅ Real provisioner for creating communities on PDS ) - postService := posts.NewPostService(postRepo, communityService, nil, pdsURL) // nil aggregatorService for user-only tests + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, pdsURL) // nil aggregatorService, blobService, unfurlService for user-only tests // Setup auth middleware (skip JWT verification for testing) authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) diff --git a/tests/integration/post_handler_test.go b/tests/integration/post_handler_test.go index 71819f6..6aa73a5 100644 --- a/tests/integration/post_handler_test.go +++ b/tests/integration/post_handler_test.go @@ -41,7 +41,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { ) postRepo := postgres.NewPostRepository(db) - postService := posts.NewPostService(postRepo, communityService, nil, "http://localhost:3001") // nil aggregatorService for user-only tests + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, "http://localhost:3001") // nil aggregatorService, blobService, unfurlService for user-only tests // Create handler handler := post.NewCreateHandler(postService) @@ -409,7 +409,7 @@ func TestPostHandler_SpecialCharacters(t *testing.T) { ) postRepo := postgres.NewPostRepository(db) - postService := posts.NewPostService(postRepo, communityService, nil, "http://localhost:3001") // nil aggregatorService for user-only tests + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, "http://localhost:3001") // nil aggregatorService, blobService, unfurlService for user-only tests handler := post.NewCreateHandler(postService) @@ -493,7 +493,7 @@ func TestPostService_DIDValidationSecurity(t *testing.T) { ) postRepo := postgres.NewPostRepository(db) - postService := posts.NewPostService(postRepo, communityService, nil, "http://localhost:3001") + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, "http://localhost:3001") t.Run("Reject posts when context DID is missing", func(t *testing.T) { // Simulate bypassing handler - no DID in context diff --git a/tests/integration/post_thumb_validation_test.go b/tests/integration/post_thumb_validation_test.go new file mode 100644 index 0000000..b1062c3 --- /dev/null +++ b/tests/integration/post_thumb_validation_test.go @@ -0,0 +1,288 @@ +package integration + +import ( + "Coves/internal/api/handlers/post" + "Coves/internal/api/middleware" + "Coves/internal/core/communities" + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestCommunityWithCredentials creates a test community with valid PDS credentials +func createTestCommunityWithCredentials(t *testing.T, repo communities.Repository, suffix string) *communities.Community { + t.Helper() + + community := &communities.Community{ + DID: "did:plc:testcommunity" + suffix, + Name: "test-community-" + suffix, + Handle: "test-community-" + suffix + ".communities.coves.local", + Description: "Test community for thumb validation", + Visibility: "public", + PDSEmail: "test@communities.coves.local", + PDSPassword: "test-password", + PDSAccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOnRlc3Rjb21tdW5pdHkxMjMiLCJleHAiOjk5OTk5OTk5OTl9.test", + PDSRefreshToken: "refresh_token_test123", + PDSURL: "http://localhost:3001", + } + + created, err := repo.Create(context.Background(), community) + require.NoError(t, err) + + return created +} + +// TestPostHandler_ThumbValidation tests strict validation of thumb field in external embeds +func TestPostHandler_ThumbValidation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + db := setupTestDB(t) + defer func() { + if err := db.Close(); err != nil { + t.Logf("Failed to close database: %v", err) + } + }() + + // Setup services + communityRepo := postgres.NewCommunityRepository(db) + communityService := communities.NewCommunityService( + communityRepo, + "http://localhost:3001", + "did:web:test.coves.social", + "test.coves.social", + nil, + ) + + postRepo := postgres.NewPostRepository(db) + // No blobService or unfurlService for these validation tests + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, "http://localhost:3001") + + handler := post.NewCreateHandler(postService) + + // Create test user and community with PDS credentials (use unique IDs) + testUser := createTestUser(t, db, "thumbtest.bsky.social", "did:plc:thumbtest"+t.Name()) + testCommunity := createTestCommunityWithCredentials(t, communityRepo, t.Name()) + + t.Run("Reject thumb as URL string", func(t *testing.T) { + payload := map[string]interface{}{ + "community": testCommunity.DID, + "title": "Test Post", + "content": "Test content", + "embed": map[string]interface{}{ + "$type": "social.coves.embed.external", + "external": map[string]interface{}{ + "uri": "https://streamable.com/test", + "thumb": "https://example.com/thumb.jpg", // ❌ URL string (invalid) + }, + }, + } + + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) + + // Mock authenticated user context + ctx := middleware.SetTestUserDID(req.Context(), testUser.DID) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + handler.HandleCreate(rec, req) + + // Should return 400 Bad Request + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var errResp map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &errResp) + require.NoError(t, err) + + assert.Contains(t, errResp["message"], "thumb must be a blob reference") + assert.Contains(t, errResp["message"], "not URL string") + }) + + t.Run("Reject thumb missing $type", func(t *testing.T) { + payload := map[string]interface{}{ + "community": testCommunity.DID, + "title": "Test Post", + "embed": map[string]interface{}{ + "$type": "social.coves.embed.external", + "external": map[string]interface{}{ + "uri": "https://streamable.com/test", + "thumb": map[string]interface{}{ // ❌ Missing $type + "ref": map[string]interface{}{"$link": "bafyrei123"}, + "mimeType": "image/jpeg", + "size": 12345, + }, + }, + }, + } + + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) + + ctx := middleware.SetTestUserDID(req.Context(), testUser.DID) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + handler.HandleCreate(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var errResp map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &errResp) + require.NoError(t, err) + + assert.Contains(t, errResp["message"], "thumb must have $type: blob") + }) + + t.Run("Reject thumb missing ref field", func(t *testing.T) { + payload := map[string]interface{}{ + "community": testCommunity.DID, + "title": "Test Post", + "embed": map[string]interface{}{ + "$type": "social.coves.embed.external", + "external": map[string]interface{}{ + "uri": "https://streamable.com/test", + "thumb": map[string]interface{}{ + "$type": "blob", + // ❌ Missing ref field + "mimeType": "image/jpeg", + "size": 12345, + }, + }, + }, + } + + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) + + ctx := middleware.SetTestUserDID(req.Context(), testUser.DID) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + handler.HandleCreate(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var errResp map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &errResp) + require.NoError(t, err) + + assert.Contains(t, errResp["message"], "thumb blob missing required 'ref' field") + }) + + t.Run("Reject thumb missing mimeType field", func(t *testing.T) { + payload := map[string]interface{}{ + "community": testCommunity.DID, + "title": "Test Post", + "embed": map[string]interface{}{ + "$type": "social.coves.embed.external", + "external": map[string]interface{}{ + "uri": "https://streamable.com/test", + "thumb": map[string]interface{}{ + "$type": "blob", + "ref": map[string]interface{}{"$link": "bafyrei123"}, + // ❌ Missing mimeType field + "size": 12345, + }, + }, + }, + } + + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) + + ctx := middleware.SetTestUserDID(req.Context(), testUser.DID) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + handler.HandleCreate(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var errResp map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &errResp) + require.NoError(t, err) + + assert.Contains(t, errResp["message"], "thumb blob missing required 'mimeType' field") + }) + + t.Run("Accept valid blob reference", func(t *testing.T) { + // Note: This test will fail at PDS write because the blob doesn't actually exist + // But it validates that our thumb validation accepts properly formatted blobs + payload := map[string]interface{}{ + "community": testCommunity.DID, + "title": "Test Post", + "embed": map[string]interface{}{ + "$type": "social.coves.embed.external", + "external": map[string]interface{}{ + "uri": "https://streamable.com/test", + "thumb": map[string]interface{}{ // ✅ Valid blob + "$type": "blob", + "ref": map[string]interface{}{"$link": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm"}, + "mimeType": "image/jpeg", + "size": 52813, + }, + }, + }, + } + + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) + + ctx := middleware.SetTestUserDID(req.Context(), testUser.DID) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + handler.HandleCreate(rec, req) + + // Should not fail with thumb validation error + // (May fail later at PDS write, but that's expected for test data) + if rec.Code == http.StatusBadRequest { + var errResp map[string]interface{} + _ = json.Unmarshal(rec.Body.Bytes(), &errResp) + // If it's a bad request, it should NOT be about thumb validation + assert.NotContains(t, errResp["message"], "thumb must be") + assert.NotContains(t, errResp["message"], "thumb blob missing") + } + }) + + t.Run("Accept missing thumb (unfurl will handle)", func(t *testing.T) { + payload := map[string]interface{}{ + "community": testCommunity.DID, + "title": "Test Post", + "embed": map[string]interface{}{ + "$type": "social.coves.embed.external", + "external": map[string]interface{}{ + "uri": "https://streamable.com/test", + // ✅ No thumb field - unfurl service will handle + }, + }, + } + + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) + + ctx := middleware.SetTestUserDID(req.Context(), testUser.DID) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + handler.HandleCreate(rec, req) + + // Should not fail with thumb validation error + if rec.Code == http.StatusBadRequest { + var errResp map[string]interface{} + _ = json.Unmarshal(rec.Body.Bytes(), &errResp) + // Should not be a thumb validation error + assert.NotContains(t, errResp["message"], "thumb must be") + } + }) +}