'array']); app(PermissionRegistrar::class)->forgetCachedPermissions(); app()->setLocale('en'); } private function opts(array $labels): array { return array_map(fn ($key, $label) => ['key' => $key, 'label' => ['en' => $label]], array_keys($labels), $labels); } public function test_answers_as_text_per_type(): void { $this->assertSame('Yes', (new YesNoType())->answerToText('yes', null, [])); $this->assertSame('Maybe, if...: after 18:00', (new YesNoConditionalType())->answerToText('maybe_if', 'after 18:00', [])); $options = ['options' => $this->opts(['a' => 'Garden', 'b' => 'Repair café']), 'allow_other' => true]; $this->assertSame('Repair café', (new SingleChoiceType())->answerToText('b', null, $options)); $this->assertSame('Other: Bike tour', (new SingleChoiceType())->answerToText('__other', 'Bike tour', $options)); $this->assertSame('Garden; Other: Bike tour', (new MultiChoiceType())->answerToText(['a', '__other'], 'Bike tour', $options)); $this->assertSame('1. Repair café; 2. Garden', (new RankingType())->answerToText(['b', 'a'], null, $options)); $slots = ['allow_if_needed' => true, 'slots' => [['key' => 's1', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null]]]; $this->assertSame('Monday 2 November 2026, 10:00: If needed', (new DateTimeSlotsType())->answerToText(['s1' => 'if_needed'], null, $slots, 'en')); $this->assertSame('4', (new ScaleType())->answerToText(4, null, ['min' => 1, 'max' => 5])); $this->assertSame('Free text', (new TextType())->answerToText(null, 'Free text', [])); } public function test_formula_injection_is_neutralised(): void { $this->assertSame("'=HYPERLINK(\"x\")", ResultsCsv::safeCell('=HYPERLINK("x")')); $this->assertSame("'+1", ResultsCsv::safeCell('+1')); $this->assertSame("'@SUM(A1)", ResultsCsv::safeCell('@SUM(A1)')); $this->assertSame('Normal answer', ResultsCsv::safeCell('Normal answer')); $this->assertSame('', ResultsCsv::safeCell('')); } /** A poll with a single choice and a text question, and two votes. */ private function pollWithVotes(bool $anonymous): Poll { $postId = DB::table('posts')->insertGetId([ 'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(), ]); $poll = Poll::create(['post_id' => $postId, 'status' => Poll::STATUS_OPEN, 'anonymous' => $anonymous]); $choice = PollQuestion::create([ 'poll_id' => $poll->id, 'position' => 1, 'type' => 'single_choice', 'label' => ['en' => 'Favourite'], 'settings' => ['options' => $this->opts(['a' => 'Garden', 'b' => 'Repair café'])], ]); $text = PollQuestion::create([ 'poll_id' => $poll->id, 'position' => 2, 'type' => 'text', 'required' => false, 'label' => ['en' => 'Remarks'], 'settings' => ['multiline' => true, 'max_length' => 100], ]); $alice = User::factory()->create(['name' => 'Alice Voter']); $this->vote($poll, $alice, $alice, [$choice->id => ['value' => 'a', 'text' => null], $text->id => ['value' => null, 'text' => '=cmd()']], '2026-10-01 10:00:00'); $organization = Organization::factory()->create(['name' => 'Garden Club']); $bob = User::factory()->create(['name' => 'Bob Manager']); $this->vote($poll, $organization, $bob, [$choice->id => ['value' => 'b', 'text' => null]], '2026-10-02 11:00:00'); return $poll; } private function vote(Poll $poll, Model $profile, User $user, array $answers, string $at): void { $response = PollResponse::create([ 'poll_id' => $poll->id, 'voter_id' => $profile->id, 'voter_type' => get_class($profile), 'user_id' => $user->id, 'dedupe_key' => $poll->dedupeKey($profile, $user->id), 'submitted_at' => $at, ]); $response->forceFill(['created_at' => $at, 'updated_at' => $at])->saveQuietly(); foreach ($answers as $questionId => $answer) { PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $questionId] + $answer); } } public function test_named_export_has_voters_and_answers(): void { $csv = new ResultsCsv($this->pollWithVotes(false), 'en'); $this->assertSame(['Voted', 'Changed', 'Profile type', 'Profile', 'Submitted by', 'Favourite', 'Remarks'], $csv->header()); $this->assertSame([ ['2026-10-01 10:00', '', 'User', 'Alice Voter', 'Alice Voter', 'Garden', "'=cmd()"], ['2026-10-02 11:00', '', 'Organization', 'Garden Club', 'Bob Manager', 'Repair café', ''], ], iterator_to_array($csv->rows(), false)); } public function test_anonymous_export_leaves_out_who_and_when(): void { $csv = new ResultsCsv($this->pollWithVotes(true), 'en'); $this->assertSame(['Favourite', 'Remarks'], $csv->header()); $rows = iterator_to_array($csv->rows(), false); $this->assertCount(2, $rows); sort($rows); $this->assertSame([['Garden', "'=cmd()"], ['Repair café', '']], $rows); $this->assertStringNotContainsString('Alice', json_encode($rows)); } public function test_download_requires_admin_with_poll_export_permission(): void { $poll = $this->pollWithVotes(false); // A User profile $user = User::factory()->create(); $this->actingAs($user, 'web'); session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]); try { (new PollResultsExportController())($poll); $this->fail('A user profile could download the results'); } catch (HttpException $e) { $this->assertSame(403, $e->getStatusCode()); } // An admin who may only view polls (or manage posts) may not see who voted $webUser = User::factory()->create(); $admin = Admin::factory()->create(); $admin->users()->attach($webUser); $role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\export-role", 'guard_name' => 'web']); $role->givePermissionTo(Permission::firstOrCreate(['name' => 'view polls', 'guard_name' => 'web'])); $role->givePermissionTo(Permission::firstOrCreate(['name' => 'manage posts', 'guard_name' => 'web'])); $webUser->assignRole($role); $this->actingAs($webUser, 'web'); $this->actingAs($admin, 'admin'); session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]); try { (new PollResultsExportController())($poll); $this->fail('An admin with only view polls could download the results'); } catch (HttpException $e) { $this->assertSame(403, $e->getStatusCode()); } // With the export permission the file comes $role->givePermissionTo(Permission::firstOrCreate(['name' => 'export polls', 'guard_name' => 'web'])); app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); $webUser->load('roles'); $response = (new PollResultsExportController())($poll); ob_start(); $response->sendContent(); $body = ob_get_clean(); $this->assertStringStartsWith("\xEF\xBB\xBF", $body); $this->assertStringContainsString('Garden Club', $body); $this->assertStringContainsString('attachment; filename=poll-' . $poll->id . '-results-', $response->headers->get('Content-Disposition')); } }