245 lines
9.0 KiB
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
|
|
}
|
|
}
|