From f452ba21e151c52b62524dfee31aed875feddc98 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 22 Oct 2026 07:49:05 +0200 Subject: [PATCH] fix: reject polls on web quotes --- KNOWLEDGE_BASE.md | 2 +- app/Http/Requests/CreateCommitRequest.php | 4 ++- tests/Feature/Controllers/CommitPollsTest.php | 28 +++++++++++++++---- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d68c799fa..ed0d1089b 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -257,7 +257,7 @@ Livestream history: on [day two at 7:31:27](https://www.youtube.com/watch?v=nfmV ### Polls and voting -Add a poll to a top-level commit with **two to four distinct options**, each up to **50 characters** (with the same emoji suggestions and `:shortcode:` conversion as the commit body), and a duration of **one hour, one day, three days, or seven days**. Polls cannot accompany images or be added to replies. Voting opens when the commit is approved, so time spent pending does not consume the voting period. +Add a poll to a top-level commit with **two to four distinct options**, each up to **50 characters** (with the same emoji suggestions and `:shortcode:` conversion as the commit body), and a duration of **one hour, one day, three days, or seven days**. Polls cannot accompany images or be added to replies or quotes. Voting opens when the commit is approved, so time spent pending does not consume the voting period. Signed-in readers can choose one option and vote once while an approved, visible poll is open; a vote cannot be changed or withdrawn. The author cannot vote in their own poll. Results are visible to the author and people who have voted, and to everyone once the poll closes. Other people's individual votes are not revealed. diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 2b0e62171..338db8c30 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -83,12 +83,14 @@ public function rules(): array $user = $this->user(); assert($user instanceof User); $body = $this->input('body'); + $attachmentRules = self::attachmentRules(); + $attachmentRules['poll'][] = 'prohibits:quoted_commit_id'; return [ 'body' => self::bodyRules($this->hasFile('images')), 'quoted_commit_id' => ['nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, 'That commit is no longer available to quote.')], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], - ...self::attachmentRules(), + ...$attachmentRules, ]; } diff --git a/tests/Feature/Controllers/CommitPollsTest.php b/tests/Feature/Controllers/CommitPollsTest.php index ae98dfd67..7af54b17b 100644 --- a/tests/Feature/Controllers/CommitPollsTest.php +++ b/tests/Feature/Controllers/CommitPollsTest.php @@ -64,7 +64,7 @@ } })->with(['chosen' => 'https://example.com/second', 'removed' => null]); -it('keeps polls and link card choices on quotes through approval without exposing quoted votes', function (?string $linkPreviewUrl): void { +it('keeps link card choices on quotes through approval without exposing quoted votes', function (?string $linkPreviewUrl): void { Queue::fake([UnfurlLink::class]); $user = User::factory()->create(); @@ -79,7 +79,6 @@ 'body' => 'Compare @quoted-writer https://example.com/first and https://example.com/second', 'quoted_commit_id' => $quoted->commit_id, 'link_preview_url' => $linkPreviewUrl, - 'poll' => ['options' => ['First', 'Second'], 'duration' => PollDuration::OneDay->value], ]) ->assertSessionHasNoErrors() ->assertRedirectToRoute('log.show', Log::Following); @@ -87,14 +86,14 @@ $quote = $user->commits()->sole(); expect($quote->quoted_commit_id)->toBe($quoted->commit_id) - ->and($quote->poll->opened_at)->toBeNull() + ->and($quote->poll)->toBeNull() ->and($quote->linkPreview?->url)->toBe($linkPreviewUrl); resolve(UpdateCommitStatus::class)->handle($quote, CommitStatus::Approved); $notification = $quotedAuthor->notifications()->sole(); - expect($quote->poll->refresh()->opened_at?->toIso8601String())->toBe(now()->toIso8601String()) + expect($quote->refresh()->status)->toBe(CommitStatus::Approved) ->and($user->notifications()->exists())->toBeFalse() ->and($notification->type)->toBe(NotificationType::Quoted) ->and($notification->actor_id)->toBe($user->id) @@ -106,8 +105,7 @@ ->assertOk() ->assertInertia(fn ($page) => $page ->where('auth.user', null) - ->where('commit.poll.options.0.label', 'First') - ->where('commit.poll.total_votes', null) + ->where('commit.poll', null) ->where('commit.quoted_commit.id', $quoted->commit_id) ->where('commit.quoted_commit.poll.options.0.label', 'Private A') ->where('commit.quoted_commit.poll.total_votes', null)) @@ -164,6 +162,24 @@ expect($parent->replies()->count())->toBe(0); }); +it('may not attach a poll to a quote', function (): void { + $user = User::factory()->create(); + $quoted = Commit::factory()->create(); + + $this->actingAs($user) + ->fromRoute('log.show', Log::Following) + ->post(route('commit.store'), [ + 'body' => 'Tabs or spaces?', + 'quoted_commit_id' => $quoted->id, + 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => PollDuration::OneDay->value], + ]) + ->assertRedirectToRoute('log.show', Log::Following) + ->assertSessionHasErrors(['poll' => 'The poll field prohibits quoted commit id from being present.']); + + expect($user->commits()->exists())->toBeFalse() + ->and(Poll::query()->exists())->toBeFalse(); +}); + it('may not attach a poll to a commit with images', function (): void { Storage::fake();