Files

222 lines
11 KiB
PHP

<?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
}
}