0, 'is_api_enabled' => true]); Queue::fake(); $this->withoutVite(); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'admin']); $this->server = Server::factory()->create(['team_id' => $this->team->id]); $this->server->settings->forceFill([ 'ssh_host_key' => PINNED_HOST_KEY, 'ssh_host_key_source' => 'tofu', 'ssh_host_key_pending' => PENDING_HOST_KEY_A, ])->save(); $this->actingAs($this->user); session(['currentTeam' => $this->team]); }); it('opens the host key page for an admin of the server team', function () { $this->get(route('server.security', ['server_uuid' => $this->server->uuid])) ->assertOk() ->assertSee('Trust new key'); }); it('trusts the pending key that the admin compared', function () { Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]) ->call('trustPendingKey', hash('sha256', PENDING_HOST_KEY_A), 'password') ->assertReturned(true); expect($this->server->settings->fresh()) ->ssh_host_key->toBe(PENDING_HOST_KEY_A) ->ssh_host_key_pending->toBeNull(); }); it('does not trust a pending key that changed after the admin compared it', function () { $component = Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]); $this->server->settings->forceFill(['ssh_host_key_pending' => PENDING_HOST_KEY_B])->save(); $component->call('trustPendingKey', hash('sha256', PENDING_HOST_KEY_A), 'password'); expect($this->server->settings->fresh()) ->ssh_host_key->toBe(PINNED_HOST_KEY) ->ssh_host_key_pending->toBe(PENDING_HOST_KEY_B); Queue::assertNotPushed(ServerConnectionCheckJob::class); }); it('sets the host key check to the state that the button showed, also when another tab changed it', function () { $component = Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]); // Another tab disabled the check after this page loaded, so this page still shows "Disable check". $this->server->settings->forceFill(['is_ssh_host_key_check_disabled' => true])->save(); $component->call('setHostKeyCheckDisabled', true, 'password')->assertReturned(true); expect($this->server->settings->fresh()->is_ssh_host_key_check_disabled)->toBeTrue(); expect(AuditEvent::query()->where('event', 'ui.server.ssh_host_key.check_disabled')->exists())->toBeTrue(); expect(AuditEvent::query()->where('event', 'ui.server.ssh_host_key.check_enabled')->exists())->toBeFalse(); }); it('enables the host key check again', function () { $this->server->settings->forceFill(['is_ssh_host_key_check_disabled' => true])->save(); Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]) ->call('setHostKeyCheckDisabled', false, 'password') ->assertReturned(true); expect($this->server->settings->fresh()->is_ssh_host_key_check_disabled)->toBeFalse(); expect(AuditEvent::query()->where('event', 'ui.server.ssh_host_key.check_enabled')->exists())->toBeTrue(); }); it('does not change the host key of a server that was transferred to another instance', function (string $action, array $arguments) { $component = Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]); $this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->save(); $component->call($action, ...$arguments); expect($this->server->settings->fresh()) ->ssh_host_key->toBe(PINNED_HOST_KEY) ->ssh_host_key_pending->toBe(PENDING_HOST_KEY_A) ->is_ssh_host_key_check_disabled->toBeFalse(); Queue::assertNotPushed(ServerConnectionCheckJob::class); })->with([ 'reset' => ['resetHostKey', ['password']], 'trust' => ['trustPendingKey', [hash('sha256', PENDING_HOST_KEY_A), 'password']], 'toggle' => ['setHostKeyCheckDisabled', [true, 'password']], ]); it('shows the security tab for a server that a changed host key stopped', function () { $this->server->settings->forceFill(['is_reachable' => false, 'is_usable' => false])->save(); Livewire::test(Navbar::class, ['server' => $this->server]) ->assertSee(route('server.security', ['server_uuid' => $this->server->uuid])); }); it('shows the patching section after the trusted key makes the server functional again', function () { $this->server->settings->forceFill(['is_reachable' => false, 'is_usable' => false])->save(); $page = Livewire::test(Index::class, ['server_uuid' => $this->server->uuid]) ->assertDontSeeLivewire(Patches::class); Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]) ->call('trustPendingKey', hash('sha256', PENDING_HOST_KEY_A), 'password') ->assertDispatched('hostKeySettingsChanged'); // The connection check that the trust runs found the server working. $this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->save(); $page->dispatch('hostKeySettingsChanged')->assertSeeLivewire(Patches::class); }); it('queues the connection check instead of running it during the request', function () { Bus::fake([ServerConnectionCheckJob::class]); Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]) ->call('trustPendingKey', hash('sha256', PENDING_HOST_KEY_A), 'password') ->assertDispatched('hostKeySettingsChanged'); Bus::assertNotDispatchedSync(ServerConnectionCheckJob::class); Bus::assertDispatched(ServerConnectionCheckJob::class, fn (ServerConnectionCheckJob $job) => $job->server->is($this->server)); }); it('polls until the queued connection check changes the server state', function () { $this->server->settings->forceFill(['is_reachable' => false, 'is_usable' => false])->save(); $page = Livewire::test(Index::class, ['server_uuid' => $this->server->uuid]) ->dispatch('hostKeySettingsChanged') ->assertSet('isWaitingForConnectionCheck', true) ->call('pollConnectionCheck') ->assertSet('isWaitingForConnectionCheck', true) ->assertDontSeeLivewire(Patches::class); $this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->save(); // The page loads again, so that the patching section and the navbar show the new state. $page->call('pollConnectionCheck') ->assertRedirect(route('server.security', ['server_uuid' => $this->server->uuid])); }); it('stops polling when the connection check takes longer than a minute', function () { $this->server->settings->forceFill(['is_reachable' => false, 'is_usable' => false])->save(); $page = Livewire::test(Index::class, ['server_uuid' => $this->server->uuid]) ->dispatch('hostKeySettingsChanged') ->assertSet('isWaitingForConnectionCheck', true); $this->travel(61)->seconds(); $page->call('pollConnectionCheck')->assertSet('isWaitingForConnectionCheck', false); }); /** * @return array}> */ function hostKeyPageActions(): array { return [ 'reset' => ['resetHostKey', ['password']], 'trust' => ['trustPendingKey', [hash('sha256', PENDING_HOST_KEY_A), 'password']], 'disable check' => ['setHostKeyCheckDisabled', [true, 'password']], ]; } function expectHostKeyUnchanged(Server $server): void { expect($server->settings->fresh()) ->ssh_host_key->toBe(PINNED_HOST_KEY) ->ssh_host_key_pending->toBe(PENDING_HOST_KEY_A) ->is_ssh_host_key_check_disabled->toBeFalse(); expect(AuditEvent::query()->where('event', 'like', 'ui.server.ssh_host_key.%')->exists())->toBeFalse(); Queue::assertNotPushed(ServerConnectionCheckJob::class); } /** * The API request must authenticate with the token only, not with the user that the page tests log in. */ function hostKeyApiHeaders(string $token): array { app('auth')->forgetGuards(); return ['Authorization' => 'Bearer '.$token]; } it('does not open the host key page for a member of the server team', function () { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); $this->user->refresh(); $this->get(route('server.security', ['server_uuid' => $this->server->uuid])) ->assertForbidden() ->assertDontSee('Trust new key'); Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]) ->assertRedirect(route('server.index')); }); it('does not change the host key for a user who became a member after the page loaded', function (string $action, array $arguments) { $component = Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]); $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); $this->user->refresh(); $component->call($action, ...$arguments) ->assertDispatched('error') ->assertNotDispatched('hostKeySettingsChanged'); expectHostKeyUnchanged($this->server); })->with(hostKeyPageActions()); it('does not open the host key page of a server of another team', function () { $outsider = User::factory()->create(); $outsiderTeam = Team::factory()->create(); $outsiderTeam->members()->attach($outsider->id, ['role' => 'owner']); $this->actingAs($outsider); session(['currentTeam' => $outsiderTeam]); $this->get(route('server.security', ['server_uuid' => $this->server->uuid])) ->assertForbidden() ->assertDontSee('Trust new key') ->assertDontSee($this->server->name); Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]) ->assertRedirect(route('server.index')); expectHostKeyUnchanged($this->server); }); it('authorizes host key changes against the server team after the session switched to another team', function (string $action, array $arguments) { $ownTeam = Team::factory()->create(); $ownTeam->members()->attach($this->user->id, ['role' => 'owner']); $component = Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]); session(['currentTeam' => $ownTeam]); $component->call($action, ...$arguments)->assertReturned(true); Queue::assertPushed(ServerConnectionCheckJob::class, fn (ServerConnectionCheckJob $job) => $job->server->is($this->server)); })->with(hostKeyPageActions()); it('denies host key changes to a member of the server team who owns the team that the session switched to', function (string $action, array $arguments) { $ownTeam = Team::factory()->create(); $ownTeam->members()->attach($this->user->id, ['role' => 'owner']); $component = Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]); $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); $this->user->refresh(); session(['currentTeam' => $ownTeam]); $component->call($action, ...$arguments) ->assertDispatched('error') ->assertNotDispatched('hostKeySettingsChanged'); expectHostKeyUnchanged($this->server); })->with(hostKeyPageActions()); it('resets the host key through the API with a write token', function () { $token = $this->user->createToken('host-key-write', ['write'])->plainTextToken; $this->withHeaders(hostKeyApiHeaders($token)) ->postJson("/api/v1/servers/{$this->server->uuid}/host-key/reset") ->assertOk() ->assertJson(['message' => 'Host key reset.']); expect($this->server->settings->fresh()) ->ssh_host_key->toBeNull() ->ssh_host_key_pending->toBeNull(); Queue::assertPushed(ServerConnectionCheckJob::class); }); it('does not reset the host key through the API with a read-only token', function () { $token = $this->user->createToken('host-key-read', ['read'])->plainTextToken; $this->withHeaders(hostKeyApiHeaders($token)) ->postJson("/api/v1/servers/{$this->server->uuid}/host-key/reset") ->assertForbidden(); expectHostKeyUnchanged($this->server); }); it('does not reset the host key through the API with a write token of a member', function () { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); $this->user->refresh(); $token = $this->user->createToken('host-key-member', ['write'])->plainTextToken; $this->withHeaders(hostKeyApiHeaders($token)) ->postJson("/api/v1/servers/{$this->server->uuid}/host-key/reset") ->assertForbidden(); expectHostKeyUnchanged($this->server); }); it('does not reset the host key of a server of another team through the API', function () { $outsider = User::factory()->create(); $outsiderTeam = Team::factory()->create(); $outsiderTeam->members()->attach($outsider->id, ['role' => 'owner']); $token = $outsider->createToken('host-key-other-team', ['write'], teamId: $outsiderTeam->id)->plainTextToken; $this->withHeaders(hostKeyApiHeaders($token)) ->postJson("/api/v1/servers/{$this->server->uuid}/host-key/reset") ->assertNotFound() ->assertJsonMissingPath('ssh_host_key_fingerprints'); expectHostKeyUnchanged($this->server); });