clusev/tests/Feature/TerminalTest.php

245 lines
9.0 KiB
PHP

<?php
namespace Tests\Feature;
use App\Livewire\Modals\HostShell;
use App\Livewire\Terminal\Index;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\SshCredential;
use App\Models\TerminalSession;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TerminalTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config()->set('clusev.terminal_secret', 'test-secret');
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
private function server(): Server
{
$s = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 2222, 'status' => 'online']);
SshCredential::create(['server_id' => $s->id, 'username' => 'deploy', 'auth_type' => 'password', 'secret' => 's3cret-pw']);
return $s;
}
private function hostCredential(): HostCredential
{
return HostCredential::create([
'host' => 'host.docker.internal', 'port' => 2200, 'username' => 'root',
'auth_type' => 'password', 'secret' => 'r00t-pw',
]);
}
private function token(string $kind, ?int $serverId = null, ?\DateTimeInterface $expires = null): string
{
$token = 'tok-'.bin2hex(random_bytes(6));
TerminalSession::create([
'token' => $token,
'user_id' => auth()->id(),
'kind' => $kind,
'server_id' => $serverId,
'expires_at' => $expires ?? now()->addMinute(),
'created_at' => now(),
]);
return $token;
}
private function resolve(string $token, string $secret = 'test-secret')
{
return $this->withHeader('X-Sidecar-Secret', $secret)
->postJson(route('internal.terminal.resolve'), ['token' => $token]);
}
// ── token minting (Livewire) ──────────────────────────────────────────────
public function test_open_host_mints_a_single_use_host_session_when_configured(): void
{
$this->hostCredential();
Livewire::test(Index::class)->call('open', 'host')->assertSet('activeKey', 'host');
$s = TerminalSession::first();
$this->assertNotNull($s);
$this->assertSame('host', $s->kind);
$this->assertNull($s->server_id);
$this->assertNull($s->used_at);
$this->assertTrue($s->expires_at->isFuture());
}
public function test_open_host_without_a_login_opens_the_setup_modal_and_mints_nothing(): void
{
Livewire::test(Index::class)
->call('open', 'host')
->assertDispatched('openModal', component: 'modals.host-shell');
$this->assertSame(0, TerminalSession::count());
}
public function test_open_server_mints_a_session_for_that_server(): void
{
$server = $this->server();
Livewire::test(Index::class)->call('open', 'server', $server->uuid)->assertSet('activeKey', $server->uuid);
$this->assertSame($server->id, TerminalSession::first()->server_id);
}
public function test_open_ignores_an_unknown_server(): void
{
Livewire::test(Index::class)->call('open', 'server', 'nope');
$this->assertSame(0, TerminalSession::count());
}
// ── resolve endpoint ──────────────────────────────────────────────────────
public function test_resolve_requires_the_sidecar_secret(): void
{
$token = $this->token('host');
$this->postJson(route('internal.terminal.resolve'), ['token' => $token])->assertForbidden();
$this->resolve($token, 'wrong-secret')->assertForbidden();
}
public function test_resolve_returns_the_host_ssh_spec_and_burns_the_token(): void
{
$this->hostCredential();
$token = $this->token('host');
$this->resolve($token)->assertOk()->assertExactJson([
'kind' => 'server', // the sidecar SSHes into the host exactly like a fleet server
'host' => 'host.docker.internal',
'port' => 2200,
'username' => 'root',
'auth_type' => 'password',
'secret' => 'r00t-pw', // decrypted by the model cast, only over the internal net
'passphrase' => null,
]);
// single use → second resolve fails
$this->resolve($token)->assertNotFound();
}
public function test_resolve_host_404s_when_no_host_login_is_configured(): void
{
$token = $this->token('host');
$this->resolve($token)->assertNotFound();
}
public function test_resolve_returns_the_server_spec_with_decrypted_credentials(): void
{
$server = $this->server();
$token = $this->token('server', $server->id);
$this->resolve($token)->assertOk()->assertExactJson([
'kind' => 'server',
'host' => '10.0.0.9',
'port' => 2222,
'username' => 'deploy',
'auth_type' => 'password',
'secret' => 's3cret-pw', // decrypted by the model cast, only over the internal net
'passphrase' => null,
]);
}
public function test_resolve_rejects_an_expired_token(): void
{
$token = $this->token('host', null, now()->subMinute());
$this->resolve($token)->assertNotFound();
}
public function test_resolve_rejects_a_server_token_whose_credential_is_locked(): void
{
$server = $this->server();
$server->credential->update(['disabled_at' => now()]);
$token = $this->token('server', $server->id);
$this->resolve($token)->assertNotFound();
}
// ── page ──────────────────────────────────────────────────────────────────
public function test_terminal_page_renders_with_the_rail(): void
{
$this->server();
$this->get(route('terminal'))->assertOk()->assertSee(__('terminal.heading'));
}
// ── host-login modal + server search ───────────────────────────────────────
public function test_host_shell_modal_saves_the_singleton(): void
{
Livewire::test(HostShell::class)
->set('port', 22)
->set('username', 'root')
->set('authType', 'password')
->set('secret', 'hunter2pw')
->call('save')
->assertDispatched('hostShellSaved');
$hc = HostCredential::current();
$this->assertNotNull($hc);
$this->assertSame('host.docker.internal', $hc->host); // fixed to the local machine, not operator-set
$this->assertSame('root', $hc->username);
$this->assertSame('hunter2pw', $hc->secret); // round-trips through the encrypted cast
}
public function test_host_shell_edit_keeps_the_stored_secret_when_left_blank(): void
{
$this->hostCredential(); // secret 'r00t-pw'
Livewire::test(HostShell::class)
->assertSet('configured', true)
->set('username', 'admin') // change something, leave secret blank
->call('save');
$hc = HostCredential::current();
$this->assertSame('admin', $hc->username);
$this->assertSame('r00t-pw', $hc->secret); // unchanged
}
public function test_host_shell_requires_a_new_secret_when_switching_auth_method(): void
{
$this->hostCredential(); // auth_type 'password', secret 'r00t-pw'
Livewire::test(HostShell::class)
->set('authType', 'key') // switch method but leave the secret blank
->set('secret', '')
->call('save')
->assertHasErrors('secret');
// The stored login is untouched — no password silently reused as a key.
$hc = HostCredential::current();
$this->assertSame('password', $hc->auth_type);
$this->assertSame('r00t-pw', $hc->secret);
}
public function test_search_filters_the_server_rail(): void
{
Server::create(['name' => 'alpha-web', 'ip' => '10.0.0.1', 'status' => 'online']);
Server::create(['name' => 'beta-db', 'ip' => '10.0.0.2', 'status' => 'online']);
Livewire::test(Index::class)
->set('search', 'alpha')
->assertSee('alpha-web')
->assertDontSee('beta-db');
}
public function test_search_box_appears_once_there_is_more_than_one_server(): void
{
Server::create(['name' => 'only-one', 'ip' => '10.0.0.1', 'status' => 'online']);
$this->get(route('terminal'))->assertDontSee(__('terminal.search_placeholder')); // 1 server → no search
Server::create(['name' => 'second', 'ip' => '10.0.0.2', 'status' => 'online']);
$this->get(route('terminal'))->assertSee(__('terminal.search_placeholder')); // 2 servers → search shows
}
}