clusev/tests/Feature/RbacSettingsGateTest.php

256 lines
8.4 KiB
PHP

<?php
namespace Tests\Feature;
use App\Livewire\Release\Index as ReleaseIndex;
use App\Livewire\Settings\Email;
use App\Livewire\Settings\LoginProtection;
use App\Livewire\Settings\Sessions;
use App\Models\BannedIp;
use App\Models\User;
use App\Services\SessionService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* RBAC guards on the SETTINGS + RELEASE admin surfaces. The RBAC foundation (Role enum, Gates
* manage-panel/manage-users) is committed separately; this proves the abort_unless(...) guards are
* wired at every dangerous entry point:
* - manage-panel gates LoginProtection (brute-force config / whitelist / ban clearing), Email (SMTP
* config) and the whole Release page + its actions;
* - manage-users gates the destructive GLOBAL logout (Sessions::logoutAll) while per-session
* self-revoke stays open to everyone.
* Admin (factory default) may act; operator + viewer are refused with 403.
*/
class RbacSettingsGateTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->create(['must_change_password' => false]);
}
private function operator(): User
{
return User::factory()->operator()->create(['must_change_password' => false]);
}
private function viewer(): User
{
return User::factory()->viewer()->create(['must_change_password' => false]);
}
// ── manage-panel: LoginProtection (mount + save + unban + unbanAll + whitelistMyIp) ──
public function test_operator_cannot_mount_login_protection(): void
{
Livewire::actingAs($this->operator())
->test(LoginProtection::class)
->assertForbidden();
}
public function test_viewer_cannot_mount_login_protection(): void
{
Livewire::actingAs($this->viewer())
->test(LoginProtection::class)
->assertForbidden();
}
public function test_admin_can_mount_login_protection(): void
{
Livewire::actingAs($this->admin())
->test(LoginProtection::class)
->assertOk();
}
public function test_operator_cannot_save_login_protection(): void
{
// Boot as admin so the snapshot is valid, then swap to an operator and call the mutation —
// the save() method-level guard must still fire 403 (models a demotion / crafted update).
$component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk();
$this->actingAs($this->operator());
$component->set('maxretry', 5)->call('save')->assertForbidden();
}
public function test_viewer_cannot_call_whitelist_my_ip(): void
{
$component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk();
$this->actingAs($this->viewer());
$component->call('whitelistMyIp')->assertForbidden();
}
public function test_operator_cannot_unban(): void
{
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
$component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk();
$this->actingAs($this->operator());
$component->call('unban', 'any-token')->assertForbidden();
$this->assertDatabaseCount('banned_ips', 1); // guard fired before the ban was touched
}
public function test_viewer_cannot_unban_all(): void
{
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
$component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk();
$this->actingAs($this->viewer());
$component->call('unbanAll', 'any-token')->assertForbidden();
$this->assertDatabaseCount('banned_ips', 1);
}
public function test_admin_can_save_login_protection(): void
{
Livewire::actingAs($this->admin())
->test(LoginProtection::class)
->set('maxretry', 7)
->call('save')
->assertHasNoErrors(); // allowed through the guard
}
// ── manage-panel: Email (mount + save) ──────────────────────────────────────
public function test_operator_cannot_mount_email(): void
{
Livewire::actingAs($this->operator())
->test(Email::class)
->assertForbidden();
}
public function test_viewer_cannot_mount_email(): void
{
Livewire::actingAs($this->viewer())
->test(Email::class)
->assertForbidden();
}
public function test_operator_cannot_save_email(): void
{
$component = Livewire::actingAs($this->admin())->test(Email::class)->assertOk();
$this->actingAs($this->operator());
$component
->set('mail_host', 'smtp.example.com')
->set('mail_from_address', 'noreply@example.com')
->set('mail_from_name', 'Clusev')
->call('save')
->assertForbidden();
}
public function test_admin_can_save_email(): void
{
Livewire::actingAs($this->admin())
->test(Email::class)
->set('mail_host', 'smtp.example.com')
->set('mail_from_address', 'noreply@example.com')
->set('mail_from_name', 'Clusev')
->call('save')
->assertHasNoErrors();
}
// ── manage-users: Sessions::logoutAll (destructive GLOBAL logout) ─────────────
public function test_operator_cannot_logout_all(): void
{
$component = Livewire::actingAs($this->admin())->test(Sessions::class)->assertOk();
$this->actingAs($this->operator());
$component->call('logoutAll', 'any-token')->assertForbidden();
}
public function test_viewer_cannot_logout_all(): void
{
$component = Livewire::actingAs($this->admin())->test(Sessions::class)->assertOk();
$this->actingAs($this->viewer());
$component->call('logoutAll', 'any-token')->assertForbidden();
}
public function test_admin_can_logout_all(): void
{
$admin = $this->admin();
// The global logout truncates every session — mock it so the test session survives.
$sessions = Mockery::mock(SessionService::class)->makePartial();
$sessions->shouldReceive('logoutEveryone')->once();
$sessions->shouldReceive('forUser')->andReturn([]);
$this->app->instance(SessionService::class, $sessions);
$this->actingAs($admin);
$token = ConfirmToken::issue('sessionsLogoutAll', [], 'session.logout_all', $admin->email);
ConfirmToken::confirm($token);
Livewire::actingAs($admin)
->test(Sessions::class)
->call('logoutAll', $token)
->assertRedirect(route('login')); // allowed through the guard → redirect to login
}
public function test_non_admin_self_session_revoke_is_not_forbidden(): void
{
// A non-admin managing their OWN sessions (per-session self-revoke) is NOT gated. Opening the
// "sign out other devices" confirm must not 403 for an operator.
Livewire::actingAs($this->operator())
->test(Sessions::class)
->call('confirmLogoutOthers')
->assertOk();
}
// ── manage-panel: Release\Index (flag ON, but role still enforced) ───────────
public function test_operator_cannot_mount_release_even_with_flag_on(): void
{
config()->set('clusev.release_controls', true);
Livewire::actingAs($this->operator())
->test(ReleaseIndex::class)
->assertForbidden();
}
public function test_viewer_cannot_mount_release_even_with_flag_on(): void
{
config()->set('clusev.release_controls', true);
Livewire::actingAs($this->viewer())
->test(ReleaseIndex::class)
->assertForbidden();
}
public function test_admin_can_mount_release_with_flag_on(): void
{
config()->set('clusev.release_controls', true);
Livewire::actingAs($this->admin())
->test(ReleaseIndex::class)
->assertOk();
}
}