+ @if (! $loaded)
+ @for ($i = 0; $i < 4; $i++)
+
+ @endfor
+ @else
@forelse ($jails as $jail)
@@ -44,9 +58,10 @@
@empty
-
{{ $loaded ? __('servers.fail2ban_no_banned') : __('servers.loading') }}
+
{{ __('servers.fail2ban_no_banned') }}
@endforelse
+ @endif
diff --git a/tests/Feature/Fail2banBansModalTest.php b/tests/Feature/Fail2banBansModalTest.php
new file mode 100644
index 0000000..704a5cb
--- /dev/null
+++ b/tests/Feature/Fail2banBansModalTest.php
@@ -0,0 +1,140 @@
+actingAs(User::factory()->create());
+ $server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
+
+ // status() must NOT be called during mount — it is deferred to load().
+ $svc = Mockery::mock(Fail2banService::class);
+ $svc->shouldReceive('status')->never();
+ app()->instance(Fail2banService::class, $svc);
+
+ Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
+ ->assertSet('loaded', false)
+ ->assertSet('jails', []);
+ }
+
+ /** load() (fired by wire:init) reads the live status and renders the jail/IP list. */
+ public function test_load_reads_status_and_renders_the_jail_list(): void
+ {
+ $this->actingAs(User::factory()->create());
+ $server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
+
+ $svc = Mockery::mock(Fail2banService::class);
+ $svc->shouldReceive('status')->once()->with(Mockery::on(fn ($s) => $s->id === $server->id))
+ ->andReturn([
+ 'jails' => [
+ ['name' => 'sshd', 'currentlyBanned' => 1, 'currentlyFailed' => 3, 'bannedIps' => ['203.0.113.7']],
+ ],
+ ]);
+ app()->instance(Fail2banService::class, $svc);
+
+ Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
+ ->call('load')
+ ->assertSet('loaded', true)
+ ->assertSee('sshd')
+ ->assertSee('203.0.113.7');
+ }
+
+ /** A read error from status() surfaces the translated error message. */
+ public function test_load_surfaces_a_read_error(): void
+ {
+ $this->actingAs(User::factory()->create());
+ $server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
+
+ $svc = Mockery::mock(Fail2banService::class);
+ $svc->shouldReceive('status')->once()->andReturn(['jails' => [], 'readError' => true]);
+ app()->instance(Fail2banService::class, $svc);
+
+ Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
+ ->call('load')
+ ->assertSet('loaded', true)
+ ->assertSet('error', __('servers.fail2ban_read_error'));
+ }
+
+ /** unban() calls the service, audits the success, refreshes the list, and signals the page. */
+ public function test_unban_calls_service_audits_refreshes_and_dispatches_change(): void
+ {
+ $this->actingAs(User::factory()->create());
+ $server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
+
+ $svc = Mockery::mock(Fail2banService::class);
+ // Initial load: one banned IP.
+ $svc->shouldReceive('status')->once()->andReturn([
+ 'jails' => [
+ ['name' => 'sshd', 'currentlyBanned' => 1, 'currentlyFailed' => 3, 'bannedIps' => ['203.0.113.7']],
+ ],
+ ]);
+ $svc->shouldReceive('unban')->once()
+ ->with(Mockery::on(fn ($s) => $s->id === $server->id), 'sshd', '203.0.113.7')
+ ->andReturn(['ok' => true, 'output' => 'unbanned']);
+ // Refresh after unban: the IP is gone.
+ $svc->shouldReceive('status')->once()->andReturn([
+ 'jails' => [
+ ['name' => 'sshd', 'currentlyBanned' => 0, 'currentlyFailed' => 3, 'bannedIps' => []],
+ ],
+ ]);
+ app()->instance(Fail2banService::class, $svc);
+
+ Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
+ ->call('load')
+ ->assertSee('203.0.113.7')
+ ->call('unbanIp', 'sshd', '203.0.113.7')
+ ->assertDispatched('fail2banChanged')
+ ->assertDispatched('notify')
+ ->assertDontSee('203.0.113.7');
+
+ $this->assertDatabaseHas('audit_events', [
+ 'server_id' => $server->id,
+ 'action' => 'fail2ban.unban',
+ ]);
+ }
+
+ /** A failed unban surfaces a notice and does NOT audit or signal the page. */
+ public function test_unban_failure_notifies_without_auditing(): void
+ {
+ $this->actingAs(User::factory()->create());
+ $server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
+
+ $svc = Mockery::mock(Fail2banService::class);
+ $svc->shouldReceive('status')->once()->andReturn([
+ 'jails' => [
+ ['name' => 'sshd', 'currentlyBanned' => 1, 'currentlyFailed' => 3, 'bannedIps' => ['203.0.113.7']],
+ ],
+ ]);
+ $svc->shouldReceive('unban')->once()->andReturn(['ok' => false, 'output' => 'no permission']);
+ app()->instance(Fail2banService::class, $svc);
+
+ Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
+ ->call('load')
+ ->call('unbanIp', 'sshd', '203.0.113.7')
+ ->assertDispatched('notify')
+ ->assertNotDispatched('fail2banChanged');
+
+ $this->assertSame(0, AuditEvent::where('action', 'fail2ban.unban')->count());
+ }
+}