clusev/tests/Feature/CommandsComponentTest.php

186 lines
6.8 KiB
PHP

<?php
namespace Tests\Feature;
use App\Livewire\Commands\Index;
use App\Models\Runbook;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\User;
use App\Services\CommandRunner;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class CommandsComponentTest extends TestCase
{
use RefreshDatabase;
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]);
}
private function server(string $name = 'box', string $ip = '10.0.0.1'): Server
{
$s = Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
$s->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
return $s;
}
// ── route gating (operate) ──────────────────────────────────────────────────
public function test_viewer_cannot_open_commands(): void
{
$this->actingAs($this->viewer())->get('/commands')->assertForbidden();
}
public function test_operator_can_open_commands(): void
{
$this->actingAs($this->operator())->get('/commands')->assertOk();
}
// ── ad-hoc run through the confirm token ────────────────────────────────────
public function test_run_opens_a_confirm_for_the_selected_targets(): void
{
$this->actingAs($this->operator());
$this->server();
Livewire::test(Index::class)
->set('command', 'uptime')
->set('scopeType', 'all')
->call('run')
->assertHasNoErrors()
->assertDispatched('openModal');
}
public function test_run_requires_a_command(): void
{
$this->actingAs($this->operator());
$this->server();
Livewire::test(Index::class)->set('command', ' ')->call('run')->assertHasErrors('command');
}
public function test_run_requires_at_least_one_target(): void
{
$this->actingAs($this->operator()); // no servers created → empty target
Livewire::test(Index::class)->set('command', 'uptime')->set('scopeType', 'all')->call('run')->assertHasErrors('command');
}
public function test_execute_runs_the_command_over_the_sealed_targets(): void
{
$this->actingAs($this->operator());
$server = $this->server();
$runner = Mockery::mock(CommandRunner::class);
$runner->shouldReceive('run')->once()
->with('uptime', Mockery::on(fn ($servers) => $servers->pluck('id')->all() === [$server->id]))
->andReturn([['server' => 'box', 'ok' => true, 'output' => 'up 3 days']]);
app()->instance(CommandRunner::class, $runner);
$token = ConfirmToken::issue('commandRun', ['command' => 'uptime', 'serverIds' => [$server->id]], 'command.run', 'uptime · 1', null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)
->call('execute', $token)
->assertOk()
->assertSet('results.0.output', 'up 3 days');
}
public function test_a_forged_run_token_executes_nothing(): void
{
$this->actingAs($this->operator());
$runner = Mockery::mock(CommandRunner::class);
$runner->shouldReceive('run')->never();
app()->instance(CommandRunner::class, $runner);
Livewire::test(Index::class)->call('execute', 'not-a-token')->assertSet('results', []);
}
// (A viewer cannot even mount the component — the route + mount operate-gate blocks the whole
// page, proven by test_viewer_cannot_open_commands; every mutating method re-gates as defence.)
// ── runbooks ────────────────────────────────────────────────────────────────
public function test_operator_creates_a_runbook_and_it_is_audited(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('runbookName', 'Restart nginx')
->set('runbookCommand', 'systemctl restart nginx')
->call('createRunbook')
->assertHasNoErrors();
$this->assertDatabaseHas('runbooks', ['name' => 'Restart nginx']);
$this->assertDatabaseHas('audit_events', ['action' => 'runbook.create', 'target' => 'Restart nginx']);
}
public function test_runbook_run_opens_a_confirm_with_the_saved_command(): void
{
$this->actingAs($this->operator());
$this->server();
$rb = Runbook::create(['name' => 'up', 'command' => 'uptime']);
Livewire::test(Index::class)
->set('scopeType', 'all')
->call('runRunbook', $rb->uuid)
->assertDispatched('openModal');
}
public function test_runbook_delete_via_a_confirmed_token(): void
{
$this->actingAs($this->operator());
$rb = Runbook::create(['name' => 'gone', 'command' => 'x']);
$token = ConfirmToken::issue('runbookDeleted', ['uuid' => $rb->uuid], 'runbook.delete', $rb->name, null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)->call('deleteRunbook', $token)->assertOk();
$this->assertDatabaseMissing('runbooks', ['id' => $rb->id]);
}
public function test_group_scope_only_targets_group_members(): void
{
$this->actingAs($this->operator());
$group = ServerGroup::create(['name' => 'web', 'color' => 'cyan']);
$member = $this->server('web1', '10.0.0.1');
$this->server('db1', '10.0.0.2'); // not in the group
$group->servers()->attach($member->id);
$runner = Mockery::mock(CommandRunner::class);
$runner->shouldReceive('run')->once()
->with('hostname', Mockery::on(fn ($servers) => $servers->pluck('id')->all() === [$member->id]))
->andReturn([]);
app()->instance(CommandRunner::class, $runner);
// Issue a token as the component would for a group scope, then execute.
$token = ConfirmToken::issue('commandRun', ['command' => 'hostname', 'serverIds' => [$member->id]], 'command.run', 'x', null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)->call('execute', $token)->assertOk();
}
}