186 lines
6.8 KiB
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();
|
|
}
|
|
}
|