clusev/tests/Feature/RbacUsersTest.php

210 lines
7.5 KiB
PHP

<?php
namespace Tests\Feature;
use App\Enums\Role;
use App\Livewire\Modals\CreateUser;
use App\Livewire\Settings\Users;
use App\Models\AuditEvent;
use App\Models\User;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
/**
* RBAC on the account-management surface: the mutating Settings\Users actions and the
* CreateUser modal are admin-only (`manage-users`), role changes go through the R5 confirm
* flow, and the last-admin invariant is never violated.
*/
class RbacUsersTest extends TestCase
{
use RefreshDatabase;
/** Issue + confirm a token so the apply handler can consume it (mirrors the modal). */
private function confirmedToken(string $event, array $params): string
{
$token = ConfirmToken::issue($event, $params);
ConfirmToken::confirm($token);
return $token;
}
// ── Gate: non-admins are forbidden, admins allowed ───────────────────────────────
public function test_non_admin_cannot_open_create(): void
{
foreach (['operator', 'viewer'] as $state) {
$user = User::factory()->{$state}()->create();
Livewire::actingAs($user)->test(Users::class)
->call('create')
->assertForbidden();
}
}
public function test_non_admin_cannot_confirm_remove(): void
{
$operator = User::factory()->operator()->create();
$victim = User::factory()->create();
Livewire::actingAs($operator)->test(Users::class)
->call('confirmRemove', $victim->id)
->assertForbidden();
}
public function test_non_admin_cannot_confirm_role_change(): void
{
$viewer = User::factory()->viewer()->create();
$target = User::factory()->operator()->create();
Livewire::actingAs($viewer)->test(Users::class)
->call('confirmRoleChange', $target->id, 'viewer')
->assertForbidden();
}
public function test_admin_may_open_create_and_confirm_role_change(): void
{
$admin = User::factory()->create();
$target = User::factory()->operator()->create();
Livewire::actingAs($admin)->test(Users::class)
->call('create')
->assertOk()
->assertDispatched('openModal');
Livewire::actingAs($admin)->test(Users::class)
->call('confirmRoleChange', $target->id, 'viewer')
->assertOk()
->assertDispatched('openModal');
}
// ── applyRoleChange: happy paths ─────────────────────────────────────────────────
public function test_admin_demotes_operator_to_viewer_and_audits(): void
{
$admin = User::factory()->create();
$target = User::factory()->operator()->create(['email' => 'op@example.com']);
$this->actingAs($admin);
$token = $this->confirmedToken('userRoleChanged', ['userId' => $target->id, 'role' => 'viewer']);
Livewire::actingAs($admin)->test(Users::class)
->call('applyRoleChange', $token)
->assertDispatched('usersChanged');
$this->assertSame(Role::Viewer, $target->fresh()->role);
$this->assertDatabaseHas('audit_events', [
'action' => 'user.role_change',
'target' => 'op@example.com',
'user_id' => $admin->id,
]);
}
public function test_admin_promotes_operator_to_admin(): void
{
$admin = User::factory()->create();
$target = User::factory()->operator()->create();
$this->actingAs($admin);
$token = $this->confirmedToken('userRoleChanged', ['userId' => $target->id, 'role' => 'admin']);
Livewire::actingAs($admin)->test(Users::class)
->call('applyRoleChange', $token);
$this->assertSame(Role::Admin, $target->fresh()->role);
}
// ── Last-admin invariant ─────────────────────────────────────────────────────────
public function test_sole_admin_cannot_be_demoted(): void
{
// Exactly one admin (self); an operator keeps the account count > 1.
$admin = User::factory()->create();
User::factory()->operator()->create();
$this->actingAs($admin);
$token = $this->confirmedToken('userRoleChanged', ['userId' => $admin->id, 'role' => 'viewer']);
Livewire::actingAs($admin)->test(Users::class)
->call('applyRoleChange', $token)
->assertNotDispatched('usersChanged');
// Role unchanged — the sole admin stays admin, no exception thrown.
$this->assertSame(Role::Admin, $admin->fresh()->role);
$this->assertSame(0, AuditEvent::where('action', 'user.role_change')->count());
}
public function test_one_of_two_admins_may_be_demoted(): void
{
$admin = User::factory()->create();
$other = User::factory()->create(); // second admin (factory default role)
$this->actingAs($admin);
$token = $this->confirmedToken('userRoleChanged', ['userId' => $other->id, 'role' => 'operator']);
Livewire::actingAs($admin)->test(Users::class)
->call('applyRoleChange', $token)
->assertDispatched('usersChanged');
$this->assertSame(Role::Operator, $other->fresh()->role);
}
public function test_removing_the_sole_admin_is_refused_and_admin_survives(): void
{
// Two accounts so the last-ACCOUNT guard passes, but only one admin (self). The
// acting admin removing itself is caught by the self-guard first; the last-admin
// removal guard is the defense-in-depth behind it. Either way: the admin survives.
$admin = User::factory()->create();
User::factory()->operator()->create();
$this->actingAs($admin);
$token = $this->confirmedToken('userRemoved', ['userId' => $admin->id]);
Livewire::actingAs($admin)->test(Users::class)
->call('remove', $token);
$this->assertDatabaseHas('users', ['id' => $admin->id]);
$this->assertSame(Role::Admin, $admin->fresh()->role);
}
// ── CreateUser modal: admin-only, default role, validation ───────────────────────
public function test_non_admin_cannot_mount_create_user(): void
{
$operator = User::factory()->operator()->create();
Livewire::actingAs($operator)->test(CreateUser::class)
->assertForbidden();
}
public function test_admin_creates_user_defaulting_to_operator(): void
{
$admin = User::factory()->create();
Livewire::actingAs($admin)->test(CreateUser::class)
->set('name', 'New Op')
->set('email', 'newop@example.com')
->call('save')
->assertHasNoErrors();
$new = User::where('email', 'newop@example.com')->first();
$this->assertNotNull($new);
$this->assertSame(Role::Operator, $new->role);
}
public function test_create_user_role_validation_rejects_invalid_role(): void
{
$admin = User::factory()->create();
Livewire::actingAs($admin)->test(CreateUser::class)
->set('name', 'Bad Role')
->set('email', 'badrole@example.com')
->set('role', 'superuser')
->call('save')
->assertHasErrors('role');
$this->assertNull(User::where('email', 'badrole@example.com')->first());
}
}