From a8a7a430d77af79a6886cf66cc951c81b1e5bf5a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 9 Sep 2026 19:04:40 +0930 Subject: [PATCH] Enforce poll scope authorization on vote endpoint --- app/Http/Controllers/PollController.php | 18 ++++ tests/Feature/PollVotePrivateAuthTest.php | 122 ++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/Feature/PollVotePrivateAuthTest.php diff --git a/app/Http/Controllers/PollController.php b/app/Http/Controllers/PollController.php index 8cbe8d4c9..d41e80f28 100644 --- a/app/Http/Controllers/PollController.php +++ b/app/Http/Controllers/PollController.php @@ -47,6 +47,24 @@ class PollController extends Controller $choice = $choices[0]; $poll = Poll::findOrFail($poll_id); + $status = Status::findOrFail($poll->status_id); + + // Mirror the getPoll scope gate: a non-public poll is only votable by + // the owner or a follower. Return 404 (not 403) to match getPoll so a + // non-follower cannot distinguish "exists but denied" from "not found". + // Uses `scope != 'public'` (matching getPoll) because vote returns the + // same PollService::get payload getPoll returns, so the write path must + // not grant a read the read path would refuse (e.g. unlisted polls). + if ($status->scope != 'public') { + $viewer = $request->user(); + if ($viewer->profile_id != $status->profile_id) { + abort_if( + ! FollowerService::follows($viewer->profile_id, $status->profile_id), + 404, + 'Poll not found.' + ); + } + } abort_if(now()->gt($poll->expires_at), 422, 'Poll expired.'); diff --git a/tests/Feature/PollVotePrivateAuthTest.php b/tests/Feature/PollVotePrivateAuthTest.php new file mode 100644 index 000000000..bb4870ede --- /dev/null +++ b/tests/Feature/PollVotePrivateAuthTest.php @@ -0,0 +1,122 @@ + true]); +}); + +/** + * Create a poll on a status with the given scope, owned by $owner. + */ +function makePoll(User $owner, string $scope): Poll +{ + $status = Status::factory()->create([ + 'profile_id' => $owner->profile_id, + 'type' => 'poll', + 'scope' => $scope, + 'visibility' => $scope, + ]); + + $poll = new Poll; + $poll->status_id = $status->id; + $poll->profile_id = $owner->profile_id; + $poll->poll_options = ['Yes', 'No']; + $poll->cached_tallies = [0, 0]; + $poll->votes_count = 0; + $poll->expires_at = now()->addDay(); + $poll->save(); + + return $poll; +} + +it('denies a non-follower voting on a private poll (matching the read gate)', function () { + $owner = User::factory()->create(); + $owner->refresh(); + $outsider = User::factory()->create(); + $outsider->refresh(); + + $poll = makePoll($owner, 'private'); + + // The read side denies the non-follower with 404. + $this->actingAs($outsider) + ->getJson("/api/v1/polls/{$poll->id}") + ->assertNotFound(); + + // The write side must deny it the same way. + $this->actingAs($outsider) + ->postJson("/api/v1/polls/{$poll->id}/votes", ['choices' => [0]]) + ->assertNotFound(); + + // No vote persisted, no tally mutation. + expect(PollVote::wherePollId($poll->id)->whereProfileId($outsider->profile_id)->exists())->toBeFalse(); + expect($poll->fresh()->votes_count)->toBe(0); +}); + +it('denies a non-follower voting on an unlisted poll', function () { + $owner = User::factory()->create(); + $owner->refresh(); + $outsider = User::factory()->create(); + $outsider->refresh(); + + $poll = makePoll($owner, 'unlisted'); + + $this->actingAs($outsider) + ->postJson("/api/v1/polls/{$poll->id}/votes", ['choices' => [0]]) + ->assertNotFound(); + + expect(PollVote::wherePollId($poll->id)->whereProfileId($outsider->profile_id)->exists())->toBeFalse(); +}); + +it('allows a follower to vote on a private poll', function () { + $owner = User::factory()->create(); + $owner->refresh(); + $follower = User::factory()->create(); + $follower->refresh(); + + Follower::create([ + 'profile_id' => $follower->profile_id, + 'following_id' => $owner->profile_id, + ]); + + $poll = makePoll($owner, 'private'); + + $this->actingAs($follower) + ->postJson("/api/v1/polls/{$poll->id}/votes", ['choices' => [0]]) + ->assertOk(); + + expect(PollVote::wherePollId($poll->id)->whereProfileId($follower->profile_id)->exists())->toBeTrue(); + expect($poll->fresh()->votes_count)->toBe(1); +}); + +it('allows the owner to vote on their own private poll', function () { + $owner = User::factory()->create(); + $owner->refresh(); + + $poll = makePoll($owner, 'private'); + + $this->actingAs($owner) + ->postJson("/api/v1/polls/{$poll->id}/votes", ['choices' => [1]]) + ->assertOk(); + + expect(PollVote::wherePollId($poll->id)->whereProfileId($owner->profile_id)->exists())->toBeTrue(); +});