Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Controllers\PollResultsExportController;
|
||||
use App\Http\Livewire\Polls\Manage;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Category;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Every poll action against every poll permission (security audit, round 2).
|
||||
* 'manage polls' implies all verbs; 'access polls' (page entry) comes from view or manage.
|
||||
*/
|
||||
class PollPermissionMatrixTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private const ALL = ['view polls', 'create polls', 'update polls', 'export polls', 'delete polls', 'manage polls'];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array', 'scout.driver' => null]);
|
||||
foreach (self::ALL as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
private function actAsAdminWith(array $permissions): Admin
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user);
|
||||
if ($permissions) {
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\matrix-role", 'guard_name' => 'web']);
|
||||
$role->syncPermissions($permissions);
|
||||
$user->assignRole($role);
|
||||
}
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
private function makePoll(string $status, bool $withVote = false): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId(['postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('post_translations')->insert(['post_id' => $postId, 'locale' => 'en', 'slug' => 'matrix-' . $postId, 'title' => 'Matrix poll ' . $postId, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$poll = Poll::create(['post_id' => $postId, 'status' => $status, 'anonymous' => false]);
|
||||
PollQuestion::create(['poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'label' => ['en' => 'Q'], 'settings' => []]);
|
||||
if ($withVote) {
|
||||
$voter = User::factory()->create(['name' => 'matrixvoter' . $poll->id]);
|
||||
PollResponse::create(['poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
|
||||
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now()]);
|
||||
}
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
/** @return array<string, array{0: array, 1: array<string, bool>}> permissions => expected outcome per action */
|
||||
public static function matrix(): array
|
||||
{
|
||||
// page create update export delete archive
|
||||
$row = fn ($p, $c, $u, $e, $d, $a) => ['page' => $p, 'create' => $c, 'update' => $u, 'export' => $e, 'delete' => $d, 'archive' => $a];
|
||||
|
||||
return [
|
||||
'no poll permission' => [[], $row(false, false, false, false, false, false)],
|
||||
'view polls' => [['view polls'], $row(true, false, false, false, false, false)],
|
||||
'view + create' => [['view polls', 'create polls'], $row(true, true, false, false, false, false)],
|
||||
'view + update' => [['view polls', 'update polls'], $row(true, false, true, false, false, true)],
|
||||
'view + export' => [['view polls', 'export polls'], $row(true, false, false, true, false, false)],
|
||||
'view + delete' => [['view polls', 'delete polls'], $row(true, false, false, false, true, false)],
|
||||
'manage polls' => [['manage polls'], $row(true, true, true, true, true, true)],
|
||||
// Posts rights give no access to polls
|
||||
'manage posts only' => [['manage posts'], $row(false, false, false, false, false, false)],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider matrix */
|
||||
public function test_permission_matrix(array $permissions, array $expected): void
|
||||
{
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$this->actAsAdminWith($permissions);
|
||||
|
||||
// Page entry (mount)
|
||||
Livewire::test(Manage::class)->assertStatus($expected['page'] ? 200 : 403);
|
||||
if (!$expected['page']) {
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create
|
||||
$before = Poll::count();
|
||||
$create = Livewire::test(Manage::class)->call('openCreateModal');
|
||||
if ($expected['create']) {
|
||||
$create->set('newPoll.title', 'Matrix created poll')->call('createPoll')->assertHasNoErrors();
|
||||
$this->assertSame($before + 1, Poll::count(), 'create');
|
||||
} else {
|
||||
$create->assertStatus(403);
|
||||
$this->assertSame($before, Poll::count(), 'create');
|
||||
}
|
||||
|
||||
// Update
|
||||
$poll = $this->makePoll(Poll::STATUS_DRAFT);
|
||||
$update = Livewire::test(Manage::class)->call('openEditModal', $poll->id)->set('form.results_visibility', Poll::RESULTS_AFTER_CLOSE)->call('savePoll');
|
||||
$this->assertSame($expected['update'] ? Poll::RESULTS_AFTER_CLOSE : Poll::RESULTS_NEVER, $poll->fresh()->results_visibility, 'update');
|
||||
|
||||
// Who voted (Results tab) and the CSV export
|
||||
$voted = $this->makePoll(Poll::STATUS_CLOSED, true);
|
||||
$results = Livewire::test(Manage::class)->call('openEditModal', $voted->id)->set('tab', 'results');
|
||||
$expected['export'] ? $results->assertSee('matrixvoter' . $voted->id) : $results->assertDontSee('matrixvoter' . $voted->id);
|
||||
$this->assertExportAllowed($expected['export'], $voted);
|
||||
|
||||
// Delete (a draft without votes)
|
||||
$draft = $this->makePoll(Poll::STATUS_DRAFT);
|
||||
$delete = Livewire::test(Manage::class)->call('confirmDelete', $draft->id);
|
||||
if ($expected['delete']) {
|
||||
$delete->call('deletePoll');
|
||||
$this->assertSoftDeleted('polls', ['id' => $draft->id]);
|
||||
} else {
|
||||
$delete->assertStatus(403); // refused at the confirmation already
|
||||
$this->assertNotSoftDeleted('polls', ['id' => $draft->id]);
|
||||
}
|
||||
|
||||
// Archive (a closed poll)
|
||||
$closed = $this->makePoll(Poll::STATUS_CLOSED, true);
|
||||
Livewire::test(Manage::class)->call('archivePoll', $closed->id);
|
||||
$this->assertSame($expected['archive'], $closed->fresh()->archived_at !== null, 'archive');
|
||||
}
|
||||
|
||||
private function assertExportAllowed(bool $allowed, Poll $poll): void
|
||||
{
|
||||
try {
|
||||
(new PollResultsExportController())($poll);
|
||||
$this->assertTrue($allowed, 'export should have been refused');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertFalse($allowed, 'export should have been allowed');
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_non_admin_profiles_get_nothing_even_with_poll_permissions(): void
|
||||
{
|
||||
// An organization whose role somehow carries 'manage polls'
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
$role = Role::firstOrCreate(['name' => "Organization\\{$organization->id}\\matrix-role", 'guard_name' => 'web']);
|
||||
$role->syncPermissions(['manage polls']);
|
||||
$user->assignRole($role);
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
|
||||
|
||||
Livewire::test(Manage::class)->assertStatus(403);
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
|
||||
// A plain user
|
||||
$plain = User::factory()->create();
|
||||
$this->actingAs($plain, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $plain->id]);
|
||||
Livewire::test(Manage::class)->assertStatus(403);
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
}
|
||||
|
||||
public function test_routes_require_poll_access(): void
|
||||
{
|
||||
$routes = app('router')->getRoutes();
|
||||
$this->assertContains('user.can:access polls', $routes->getByName('polls.manage')->gatherMiddleware());
|
||||
$this->assertContains('user.can:access polls', $routes->getByName('polls.export')->gatherMiddleware());
|
||||
}
|
||||
|
||||
public function test_database_update_gives_manage_polls_to_every_admin_role(): void
|
||||
{
|
||||
foreach (['view posts', 'manage posts'] as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$definition = \App\Models\AdminRole::firstOrCreate(['name' => 'matrix-type'], ['label' => 'Matrix', 'permissions' => ['view posts']]);
|
||||
$admin = Admin::factory()->create();
|
||||
$typed = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\matrix-type", 'guard_name' => 'web']);
|
||||
$typed->syncPermissions(['view posts']);
|
||||
$legacy = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\admin", 'guard_name' => 'web']);
|
||||
$legacy->syncPermissions(['manage posts']);
|
||||
$organizationRole = Role::firstOrCreate(['name' => 'Organization\\999999\\organization-manager', 'guard_name' => 'web']);
|
||||
|
||||
$command = app(\App\Console\Commands\DatabaseUpdate::class);
|
||||
$command->setOutput(new \Illuminate\Console\OutputStyle(new \Symfony\Component\Console\Input\ArrayInput([]), new \Symfony\Component\Console\Output\NullOutput()));
|
||||
$method = new \ReflectionMethod($command, 'addPollPermissions');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($command);
|
||||
$method->invoke($command); // safe to run again
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$this->assertContains('manage polls', $definition->fresh()->permissions);
|
||||
$this->assertContains('view posts', $definition->fresh()->permissions); // others kept
|
||||
$this->assertSame(1, collect($definition->fresh()->permissions)->filter(fn ($p) => $p === 'manage polls')->count());
|
||||
$this->assertTrue($typed->fresh()->hasPermissionTo('manage polls'));
|
||||
$this->assertTrue($legacy->fresh()->hasPermissionTo('manage polls'));
|
||||
$this->assertTrue($legacy->fresh()->hasPermissionTo('manage posts')); // added, not re-synced
|
||||
$this->assertFalse($organizationRole->fresh()->hasPermissionTo('manage polls')); // admin roles only
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user