package actor import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "Coves/internal/api/middleware" "Coves/internal/core/blueskypost" "Coves/internal/core/posts" "Coves/internal/core/users" "Coves/internal/core/votes" oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" ) // mockPostService implements posts.Service for testing type mockPostService struct { getAuthorPostsFunc func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) } func (m *mockPostService) GetAuthorPosts(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { if m.getAuthorPostsFunc != nil { return m.getAuthorPostsFunc(ctx, req) } return &posts.GetAuthorPostsResponse{ Feed: []*posts.FeedViewPost{}, Cursor: nil, }, nil } func (m *mockPostService) CreatePost(ctx context.Context, session *oauthlib.ClientSessionData, req posts.CreatePostRequest) (*posts.CreatePostResponse, error) { return nil, nil } func (m *mockPostService) UpdatePost(context.Context, *oauthlib.ClientSessionData, posts.UpdatePostRequest) (*posts.UpdatePostResponse, error) { return nil, nil } func (m *mockPostService) DeletePost(ctx context.Context, session *oauthlib.ClientSessionData, req posts.DeletePostRequest) error { return nil } func (m *mockPostService) GetPosts(ctx context.Context, req posts.GetPostsRequest) ([]*posts.PostResult, error) { return nil, nil } // mockUserService implements users.UserService for testing type mockUserService struct { resolveHandleToDIDFunc func(ctx context.Context, handle string) (string, error) } func (m *mockUserService) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { return nil, nil } func (m *mockUserService) GetUserByDID(ctx context.Context, did string) (*users.User, error) { return nil, nil } func (m *mockUserService) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { return nil, nil } func (m *mockUserService) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { return nil, nil } func (m *mockUserService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { if m.resolveHandleToDIDFunc != nil { return m.resolveHandleToDIDFunc(ctx, handle) } return "did:plc:testuser", nil } func (m *mockUserService) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { return nil, nil } func (m *mockUserService) RequestSignupToken(ctx context.Context, req users.RequestSignupTokenRequest) (*users.RequestSignupTokenResponse, error) { return nil, nil } func (m *mockUserService) IndexUser(ctx context.Context, did, handle, pdsURL string) error { return nil } // IndexAuthenticatedUser is on users.UserService and unreachable from getPosts; // it is here so this double still satisfies the interface. func (m *mockUserService) IndexAuthenticatedUser(ctx context.Context, did, handle, pdsURL string) error { return nil } // IsAccountDeleted is on users.UserService and unreachable from getPosts; it is // here so this double still satisfies the interface. func (m *mockUserService) IsAccountDeleted(ctx context.Context, did string) (bool, error) { return false, nil } func (m *mockUserService) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { return nil, nil } func (m *mockUserService) DeleteAccount(ctx context.Context, did string) error { return nil } func (m *mockUserService) UpdateProfile(ctx context.Context, did string, input users.UpdateProfileInput) (*users.User, error) { return nil, nil } // mockVoteService implements votes.Service for testing type mockVoteService struct{} func (m *mockVoteService) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) { return nil, nil } func (m *mockVoteService) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error { return nil } func (m *mockVoteService) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error { return nil } func (m *mockVoteService) GetViewerVote(userDID, subjectURI string) *votes.CachedVote { return nil } func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote { return nil } // mockBlueskyService implements blueskypost.Service for testing type mockBlueskyService struct{} func (m *mockBlueskyService) ResolvePost(ctx context.Context, atURI string) (*blueskypost.BlueskyPostResult, error) { return nil, nil } func (m *mockBlueskyService) ParseBlueskyURL(ctx context.Context, url string) (string, error) { return "", nil } func (m *mockBlueskyService) IsBlueskyURL(url string) bool { return false } func TestGetPostsHandler_Success(t *testing.T) { mockPosts := &mockPostService{ getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { return &posts.GetAuthorPostsResponse{ Feed: []*posts.FeedViewPost{ { Post: &posts.PostView{ URI: "at://did:plc:testuser/social.coves.community.post/abc123", CID: "bafytest123", }, }, }, }, nil }, } mockUsers := &mockUserService{} mockVotes := &mockVoteService{} mockBluesky := &mockBlueskyService{} handler := NewGetPostsHandler(mockPosts, mockUsers, mockVotes, mockBluesky) req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:testuser", nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", rec.Code) } var response posts.GetAuthorPostsResponse if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { t.Fatalf("Failed to decode response: %v", err) } if len(response.Feed) != 1 { t.Errorf("Expected 1 post in feed, got %d", len(response.Feed)) } } func TestGetPostsHandler_MissingActorParameter(t *testing.T) { handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts", nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("Expected status 400, got %d", rec.Code) } var response ErrorResponse if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { t.Fatalf("Failed to decode response: %v", err) } if response.Error != "InvalidRequest" { t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) } } func TestGetPostsHandler_InvalidLimitParameter(t *testing.T) { handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:test&limit=abc", nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("Expected status 400, got %d", rec.Code) } var response ErrorResponse if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { t.Fatalf("Failed to decode response: %v", err) } if response.Error != "InvalidRequest" { t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) } } func TestGetPostsHandler_ActorNotFound(t *testing.T) { mockUsers := &mockUserService{ resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { return "", posts.ErrActorNotFound }, } handler := NewGetPostsHandler(&mockPostService{}, mockUsers, &mockVoteService{}, &mockBlueskyService{}) req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=nonexistent.user", nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("Expected status 404, got %d", rec.Code) } } func TestGetPostsHandler_ActorLengthExceedsMax(t *testing.T) { handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) // Create an actor parameter that exceeds 2048 characters using valid URL characters longActorBytes := make([]byte, 2100) for i := range longActorBytes { longActorBytes[i] = 'a' } longActor := "did:plc:" + string(longActorBytes) req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor="+longActor, nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("Expected status 400, got %d", rec.Code) } } func TestGetPostsHandler_InvalidCursor(t *testing.T) { mockPosts := &mockPostService{ getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { return nil, posts.ErrInvalidCursor }, } handler := NewGetPostsHandler(mockPosts, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:test&cursor=invalid", nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("Expected status 400, got %d", rec.Code) } var response ErrorResponse if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { t.Fatalf("Failed to decode response: %v", err) } if response.Error != "InvalidCursor" { t.Errorf("Expected error 'InvalidCursor', got '%s'", response.Error) } } func TestGetPostsHandler_MethodNotAllowed(t *testing.T) { handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.getPosts", nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("Expected status 405, got %d", rec.Code) } } func TestGetPostsHandler_HandleResolution(t *testing.T) { resolvedDID := "" mockPosts := &mockPostService{ getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { resolvedDID = req.ActorDID return &posts.GetAuthorPostsResponse{Feed: []*posts.FeedViewPost{}}, nil }, } mockUsers := &mockUserService{ resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { if handle == "test.user" { return "did:plc:resolveduser123", nil } return "", posts.ErrActorNotFound }, } handler := NewGetPostsHandler(mockPosts, mockUsers, &mockVoteService{}, &mockBlueskyService{}) req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=test.user", nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", rec.Code) } if resolvedDID != "did:plc:resolveduser123" { t.Errorf("Expected resolved DID 'did:plc:resolveduser123', got '%s'", resolvedDID) } } // TestGetPostsHandler_ViewerDIDComesFromTheAuthMiddleware is a PROVENANCE pin, // and on this endpoint it is the sharpest of the set: actor.getPosts is the one // read path whose whole job is "show me THIS author's posts". // // Under author-owned posts, ViewerDID no longer only drives block filtering — it // unlocks the author carve-out inside the read-path visibility predicate // (`p.author_did = $viewer` in visiblePostsJoin), which is what lets an author // see their own pending / rejected / removed posts on their own profile. So a // caller who could set both `actor` and the viewer identity from the query string // would be able to ask for a victim's profile AS that victim and receive every // post the victim's communities never admitted. // // The current handler is correct — it takes the DID from middleware.GetUserDID // and assigns it after parseRequest — but nothing pinned that, and the mutation a // reviewer demonstrated (a three-line "preview as user" override) left the whole // suite green. Both halves are asserted: absent without auth even when the query // begs for it, and exactly the context DID when authenticated. func TestGetPostsHandler_ViewerDIDComesFromTheAuthMiddleware(t *testing.T) { const victim = "did:plc:victimauthor" const adversarialQuery = "actor=" + victim + "&viewer=" + victim + "&viewerDid=" + victim + "&viewer_did=" + victim + "&author=" + victim + "&as=" + victim capture := func(t *testing.T, ctxDID string) posts.GetAuthorPostsRequest { t.Helper() var got posts.GetAuthorPostsRequest mockPosts := &mockPostService{ getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { got = req return &posts.GetAuthorPostsResponse{Feed: []*posts.FeedViewPost{}}, nil }, } handler := NewGetPostsHandler(mockPosts, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?"+adversarialQuery, nil) if ctxDID != "" { req = req.WithContext(middleware.SetTestUserDID(req.Context(), ctxDID)) } handler.HandleGetPosts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) } return got } t.Run("an unauthenticated request carries no viewer, whatever it asks for", func(t *testing.T) { t.Parallel() got := capture(t, "") if got.ActorDID != victim { t.Fatalf("ActorDID = %q, want %q — the fixture must actually be asking for the victim's profile", got.ActorDID, victim) } if got.ViewerDID != "" { t.Errorf("ViewerDID = %q for an UNAUTHENTICATED request, want \"\". A query parameter became the viewer "+ "identity, so an anonymous caller can read %q's pending, rejected and removed posts through the "+ "visibility predicate's author branch", got.ViewerDID, victim) } }) t.Run("an authenticated request carries the context DID, and no parameter displaces it", func(t *testing.T) { t.Parallel() const authenticated = "did:plc:realsessionviewer" got := capture(t, authenticated) if got.ViewerDID != authenticated { t.Errorf("ViewerDID = %q, want %q — the viewer identity must come from the auth middleware's context "+ "value and never from the query string, however the query spells it", got.ViewerDID, authenticated) } }) } func TestGetPostsHandler_DirectDIDPassthrough(t *testing.T) { receivedDID := "" mockPosts := &mockPostService{ getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { receivedDID = req.ActorDID return &posts.GetAuthorPostsResponse{Feed: []*posts.FeedViewPost{}}, nil }, } handler := NewGetPostsHandler(mockPosts, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) // When actor is already a DID, it should pass through without resolution req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:directuser", nil) rec := httptest.NewRecorder() handler.HandleGetPosts(rec, req) if rec.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", rec.Code) } if receivedDID != "did:plc:directuser" { t.Errorf("Expected DID 'did:plc:directuser', got '%s'", receivedDID) } }