210 lines
7.5 KiB
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());
|
|
}
|
|
}
|