Migrate to Gitea, switch JS tooling to oxlint/oxfmt, lift test coverage to 95%
All checks were successful
CI / Tests (push) Successful in 43s
CI / Lint (push) Successful in 1m3s

- Add .gitea/workflows/ci.yml ported from lifeos (lint + tests with coverage gate)
- Set up phpstan (larastan + peststan, baseline at level max)
- Replace eslint/prettier with oxlint/oxfmt; reformat resources/
- Add composer phpstan/coverage/quality scripts; restore --min=95 coverage gate
- Exclude integration plumbing (Saloon Hetzner classes, SSH wrappers, console
  commands, DTOs) from coverage to keep the gate focused on business logic
- Add ~12 new test files covering models, drivers, controllers, jobs, auth
  flows, request validators, and the IP CIDR helper
- Fix Support\Ip::inNetwork PHP 8.4 TypeError in CIDR mask check
- Fix FirewallRule::command comparing the enum-cast type column to a string
- Fix Server::network using the wrong foreign key column
- Remove unreachable code under abort(403) in RegisteredUserController
This commit is contained in:
2026-05-13 16:51:07 +01:00
parent aa680b25fd
commit 66f0ee9e50
238 changed files with 9243 additions and 1682 deletions

View File

@@ -0,0 +1,30 @@
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Support\Facades\Notification;
use function Pest\Laravel\actingAs;
it('sends the verification email when the user is unverified', function () {
Notification::fake();
$user = User::factory()->unverified()->create();
$response = actingAs($user)->post(route('verification.send'));
Notification::assertSentTo($user, VerifyEmail::class);
$response->assertRedirect();
$response->assertSessionHas('status', 'verification-link-sent');
});
it('redirects verified users to the intended dashboard', function () {
Notification::fake();
$user = User::factory()->create();
$response = actingAs($user)->post(route('verification.send'));
Notification::assertNothingSent();
$response->assertRedirect(route('dashboard', absolute: false));
});

View File

@@ -1,25 +1,18 @@
<?php
use function PHPUnit\Framework\assertTrue;
test('registration screen can be rendered', function () {
it('renders the registration screen', function () {
$response = $this->get('/register');
$response->assertStatus(200);
});
// test('new users can register', function () {
// $response = $this->post('/register', [
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// 'password' => 'password',
// 'password_confirmation' => 'password',
// ]);
it('rejects new registrations because registration is disabled', function () {
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
// $this->assertAuthenticated();
// assertTrue(auth()->user()->organisations()->count() === 1);
// assertTrue(auth()->user()->ownedOrganisations()->count() === 1);
// $response->assertRedirect(route('dashboard', absolute: false));
// });
$response->assertForbidden();
});

View File

@@ -0,0 +1,74 @@
<?php
use App\Models\User;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use function Pest\Laravel\actingAs;
it('locks out login after five failed attempts and fires a Lockout event', function () {
Event::fake([Lockout::class]);
$user = User::factory()->create();
for ($i = 0; $i < 5; $i++) {
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
}
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors('email');
Event::assertDispatched(Lockout::class);
RateLimiter::clear(\Illuminate\Support\Str::lower($user->email).'|127.0.0.1');
});
it('redirects already-verified users away from the verification prompt', function () {
$user = User::factory()->create();
$response = actingAs($user)->get('/verify-email');
$response->assertRedirect(route('dashboard', absolute: false));
});
it('redirects users with a verified email back to the dashboard when re-verifying', function () {
$user = User::factory()->create();
$verificationUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = actingAs($user)->get($verificationUrl);
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
});
it('renders the password settings page for an authenticated user', function () {
$user = User::factory()->create();
$response = actingAs($user)->get('/settings/password');
$response->assertOk();
});
it('updates the password when the current password is correct', function () {
$user = User::factory()->create();
$response = actingAs($user)->put('/settings/password', [
'current_password' => 'password',
'password' => 'new-secret-pw',
'password_confirmation' => 'new-secret-pw',
]);
$response->assertSessionHasNoErrors();
expect(\Illuminate\Support\Facades\Hash::check('new-secret-pw', $user->fresh()->password))->toBeTrue();
});

View File

@@ -0,0 +1,65 @@
<?php
use App\Drivers\Caddy\Caddy2Driver;
use App\Enums\ServiceType;
use App\Models\Service;
use App\Models\ServiceSlice;
it('returns a two-step operation plan referencing the service id', function () {
$service = new Service(['id' => 5]);
$plan = (new Caddy2Driver(service: $service))->getOperationPlan('hash');
expect($plan->steps)->toHaveCount(2);
expect($plan->steps[0]->getScript())->toContain('mkdir -p /home/keystone/gateway /home/keystone/services/5');
expect($plan->steps[1]->getScript())->toContain('docker compose -f /home/keystone/services/5/compose.yml up -d');
});
it('reports static metadata about the caddy driver', function () {
$driver = new Caddy2Driver;
expect($driver->serviceType())->toBe(ServiceType::CADDY);
expect($driver->versionTrack())->toBe('2');
expect($driver->defaultImage())->toBe('caddy:2');
expect($driver->defaultPorts())->toBe([80, 443]);
expect($driver->firewallRules())->toBe(['80/tcp', '443/tcp']);
expect($driver->environmentSchema())->toBe([]);
expect($driver->resourceDefaults())->toBe([]);
expect($driver->updateBehavior())->toBe('stateless_redeploy');
expect($driver->supportedSliceTypes())->toBe(['route']);
expect($driver->environmentExports())->toBe([]);
});
it('renders a compose service with caddy volumes', function () {
$service = new Service(['id' => 8]);
$compose = (new Caddy2Driver(service: $service))->composeService();
expect($compose['image'])->toBe('caddy:2');
expect($compose['ports'])->toBe(['80:80', '443:443']);
expect($compose['volumes'])->toContain('keystone_service_8_caddy_data:/data');
});
it('declares the caddy data and config volumes', function () {
$service = new Service(['id' => 8]);
expect((new Caddy2Driver(service: $service))->composeVolumes())->toBe([
'keystone_service_8_caddy_data' => null,
'keystone_service_8_caddy_config' => null,
]);
});
it('returns an empty environment export for a slice', function () {
$slice = new ServiceSlice;
expect((new Caddy2Driver)->environmentExportsForSlice($slice))->toBe([]);
});
it('emits a provisioning script for a route slice', function () {
$slice = new ServiceSlice(['id' => 42]);
$script = (new Caddy2Driver)->provisionSliceScript($slice);
expect($script)->toContain('mkdir -p /home/keystone/gateway/Caddyfile.d');
expect($script)->toContain('Caddyfile.d/42.caddy');
});

View File

@@ -0,0 +1,91 @@
<?php
use App\Drivers\Laravel\LaravelRuntimeDriver;
use App\Enums\ServiceType;
use App\Models\Service;
it('reports static metadata about the laravel runtime driver', function () {
$driver = new LaravelRuntimeDriver;
expect($driver->serviceType())->toBe(ServiceType::LARAVEL);
expect($driver->versionTrack())->toBe('php-8.4');
expect($driver->defaultImage())->toBe('serversideup/php:8.4-frankenphp');
expect($driver->defaultPorts())->toBe([80]);
expect($driver->firewallRules())->toBe([]);
expect($driver->environmentSchema())->toBe([
'APP_ENV' => 'string',
'SERVER_NAME' => 'string',
]);
expect($driver->resourceDefaults())->toBe([]);
expect($driver->updateBehavior())->toBe('stateless_gateway_cutover');
expect($driver->composeVolumes())->toBe([]);
});
it('builds a compose service with healthchecks for non-worker services', function () {
$service = new Service([
'id' => 1,
'process_roles' => ['web'],
'config' => [],
'desired_replicas' => 1,
]);
$compose = (new LaravelRuntimeDriver(service: $service))->composeService();
expect($compose['image'])->toBe('serversideup/php:8.4-frankenphp');
expect($compose['restart'])->toBe('unless-stopped');
expect($compose['healthcheck']['test'])->toContain('CMD-SHELL');
});
it('omits the healthcheck for worker services and adds custom command/cpu/memory', function () {
$service = new Service([
'id' => 2,
'process_roles' => ['worker'],
'config' => ['command' => 'php artisan queue:work'],
'default_cpu_limit' => 2,
'default_memory_limit_mb' => 512,
'desired_replicas' => 1,
]);
$compose = (new LaravelRuntimeDriver(service: $service))->composeService();
expect($compose)->not->toHaveKey('healthcheck');
expect($compose['command'])->toBe('php artisan queue:work');
expect($compose['cpus'])->toBe('2.000');
expect($compose['mem_limit'])->toBe('512m');
expect($compose['memswap_limit'])->toBe('512m');
});
it('emits a dockerfile that defers to env-supplied php and document root values', function () {
$service = new Service([
'config' => [
'php_version' => '8.3',
'document_root' => 'public-html',
'js_build_command' => 'bun run build',
],
]);
$dockerfile = (new LaravelRuntimeDriver(service: $service))->dockerfileTemplate();
expect($dockerfile)->toContain('FROM serversideup/php:8.3-frankenphp');
expect($dockerfile)->toContain('SERVER_DOCUMENT_ROOT=/var/www/html/public-html');
expect($dockerfile)->toContain('bun install --frozen-lockfile && bun run build');
});
it('supports the npm package manager in dockerfile generation', function () {
$service = new Service([
'config' => [
'js_build_command' => 'npm run build',
'js_package_manager' => 'npm',
],
]);
expect((new LaravelRuntimeDriver(service: $service))->dockerfileTemplate())
->toContain('npm ci && npm run build');
});
it('skips js build steps when no build command is configured', function () {
$service = new Service(['config' => []]);
expect((new LaravelRuntimeDriver(service: $service))->dockerfileTemplate())
->not->toContain('bun install');
});

View File

@@ -0,0 +1,110 @@
<?php
use App\Drivers\Valkey\Valkey8Driver;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Models\Service;
use App\Models\ServiceSlice;
it('returns a two-step operation plan', function () {
$service = new Service(['id' => 7]);
$plan = (new Valkey8Driver(service: $service))->getOperationPlan('hash');
expect($plan->steps)->toHaveCount(2);
expect($plan->steps[0]->name)->toBe('Render Compose file');
expect($plan->steps[1]->getScript())->toContain('compose -f /home/keystone/services/7/compose.yml');
});
it('reports static metadata about the valkey driver', function () {
$driver = new Valkey8Driver;
expect($driver->serviceType())->toBe(ServiceType::VALKEY);
expect($driver->versionTrack())->toBe('8');
expect($driver->defaultImage())->toBe('valkey/valkey:8');
expect($driver->defaultPorts())->toBe([6379]);
expect($driver->firewallRules())->toBe(['6379/tcp']);
expect($driver->environmentSchema())->toBe([]);
expect($driver->resourceDefaults())->toBe([]);
expect($driver->updateBehavior())->toBe('stateful_downtime');
expect($driver->supportedSliceTypes())->toBe(['logical_database']);
expect($driver->environmentExports())->toBe([]);
});
it('exports redis env vars for a slice and adapts to the attachment role', function () {
$service = new Service(['id' => 3, 'name' => 'cache']);
$slice = new ServiceSlice([
'service_id' => 3,
'config' => ['host' => 'valkey-host', 'port' => 7000, 'database' => 2],
]);
$slice->setRelation('service', $service);
$driver = new Valkey8Driver(service: $service);
$base = $driver->environmentExportsForSlice($slice);
expect($base)->toMatchArray([
'REDIS_HOST' => 'valkey-host',
'REDIS_PORT' => '7000',
'REDIS_DB' => '2',
]);
expect($driver->environmentExportsForSlice($slice, EnvironmentAttachmentRole::CACHE))
->toHaveKey('CACHE_STORE', 'redis');
expect($driver->environmentExportsForSlice($slice, EnvironmentAttachmentRole::QUEUE))
->toHaveKey('QUEUE_CONNECTION', 'redis');
expect($driver->environmentExportsForSlice($slice, EnvironmentAttachmentRole::DATABASE))
->not->toHaveKey('CACHE_STORE');
});
it('uses defaults when slice config is missing', function () {
$service = new Service(['id' => 3]);
$slice = new ServiceSlice(['service_id' => 3, 'config' => []]);
$driver = new Valkey8Driver(service: $service);
expect($driver->environmentExportsForSlice($slice))->toBe([
'REDIS_HOST' => 'keystone-service-3',
'REDIS_PORT' => '6379',
'REDIS_DB' => '0',
]);
});
it('builds a slice provision script using the service slug', function () {
$service = new Service(['id' => 9, 'name' => 'My Cache']);
$slice = new ServiceSlice(['service_id' => 9, 'config' => ['database' => 4]]);
$slice->setRelation('service', $service);
$driver = new Valkey8Driver(service: $service);
$script = $driver->provisionSliceScript($slice);
expect($script)->toContain('docker compose -f /home/keystone/services/9/compose.yml exec -T my_cache valkey-cli');
expect($script)->toContain("-n '4' PING");
});
it('renders a compose service without persistence by default', function () {
$service = new Service(['id' => 1, 'config' => []]);
$compose = (new Valkey8Driver(service: $service))->composeService();
expect($compose['image'])->toBe('valkey/valkey:8');
expect($compose)->not->toHaveKey('volumes');
expect($compose)->not->toHaveKey('command');
});
it('renders a compose service with persistence enabled', function () {
$service = new Service(['id' => 1, 'config' => ['persistence' => true]]);
$driver = new Valkey8Driver(service: $service);
$compose = $driver->composeService();
expect($compose['volumes'])->toBe(['keystone_service_1_valkey_data:/data']);
expect($compose['command'])->toBe(['valkey-server', '--appendonly', 'yes']);
expect($driver->composeVolumes())->toBe(['keystone_service_1_valkey_data' => null]);
});
it('returns no compose volumes without persistence', function () {
$service = new Service(['id' => 1, 'config' => []]);
expect((new Valkey8Driver(service: $service))->composeVolumes())->toBe([]);
});

View File

@@ -0,0 +1,55 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
use function Pest\Laravel\actingAs;
beforeEach(function () {
$this->user = User::factory()->create();
actingAs($this->user);
});
it('returns the environment show inertia view', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->create([
'organisation_id' => $organisation->id,
]);
$environment = Environment::factory()->create([
'application_id' => $application->id,
]);
$response = $this->get(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('environments/Show', false));
});
it('404s when the environment does not belong to the application', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->create([
'organisation_id' => $organisation->id,
]);
$otherApplication = Application::factory()->create([
'organisation_id' => $organisation->id,
]);
$environment = Environment::factory()->create([
'application_id' => $otherApplication->id,
]);
$response = $this->get(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$response->assertNotFound();
});

View File

@@ -0,0 +1,11 @@
<?php
use App\Events\Servers\ServerProvisioned;
use App\Models\Server;
it('returns an empty broadcast channel list', function () {
$server = new Server;
$event = new ServerProvisioned($server);
expect($event->broadcastOn())->toBe([]);
});

View File

@@ -0,0 +1,258 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentAttachment;
use App\Models\EnvironmentVariable;
use App\Models\Network;
use App\Models\Operation;
use App\Models\OperationStep;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Registry;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceEndpoint;
use App\Models\ServiceSlice;
use App\Models\SourceProvider;
use App\Models\User;
function buildOrgServer(): array
{
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = Network::create([
'organisation_id' => $organisation->id,
'provider_id' => $provider->id,
'name' => 'keystone',
'ip_range' => '10.0.0.0/24',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider($provider->id)
->forNetwork((string) $network->id)
->create();
return compact('organisation', 'provider', 'network', 'server');
}
it('exposes the server relations and ssh client builder', function () {
['server' => $server, 'network' => $network, 'organisation' => $organisation, 'provider' => $provider] = buildOrgServer();
expect($server->network->is($network))->toBeTrue();
expect($server->organisation->is($organisation))->toBeTrue();
expect($server->provider->is($provider))->toBeTrue();
expect($server->services)->toBeEmpty();
expect($server->serviceReplicas)->toBeEmpty();
expect($server->firewallRules)->toBeEmpty();
expect($server->serviceOperations)->toBeEmpty();
expect($server->sshClient())->toBeInstanceOf(\Spatie\Ssh\Ssh::class);
expect($server->sshClient('deploy'))->toBeInstanceOf(\Spatie\Ssh\Ssh::class);
});
it('exposes the network relations', function () {
['organisation' => $organisation, 'provider' => $provider, 'network' => $network] = buildOrgServer();
expect($network->servers)->toHaveCount(1);
expect($network->organisation->is($organisation))->toBeTrue();
expect($network->provider->is($provider))->toBeTrue();
});
it('exposes the service endpoint relations', function () {
['server' => $server] = buildOrgServer();
$service = Service::factory()->for($server)->create([
'organisation_id' => $server->organisation_id,
]);
$replica = $service->replicas()->create([
'server_id' => $server->id,
'container_name' => 'web-1',
'internal_host' => 'web-1',
'internal_port' => 8080,
]);
$endpoint = ServiceEndpoint::create([
'service_id' => $service->id,
'service_replica_id' => $replica->id,
'hostname' => 'web.local',
'port' => 8080,
'scope' => 'docker_network',
]);
expect($endpoint->service->is($service))->toBeTrue();
expect($endpoint->serviceReplica->is($replica))->toBeTrue();
});
it('exposes the environment variable relations and casts', function () {
['organisation' => $organisation] = buildOrgServer();
$application = Application::factory()->create(['organisation_id' => $organisation->id]);
$environment = Environment::factory()->create(['application_id' => $application->id]);
$variable = EnvironmentVariable::create([
'environment_id' => $environment->id,
'key' => 'DB_HOST',
'value' => 'localhost',
'source' => 'user',
'overridable' => true,
]);
expect($variable->environment->is($environment))->toBeTrue();
expect($variable->serviceSlice)->toBeNull();
expect($variable->overridable)->toBeTrue();
expect($variable->value)->toBe('localhost');
});
it('exposes the service slice relations', function () {
['organisation' => $organisation, 'server' => $server] = buildOrgServer();
$service = Service::factory()->for($server)->create([
'organisation_id' => $organisation->id,
]);
$application = Application::factory()->create(['organisation_id' => $organisation->id]);
$environment = Environment::factory()->create(['application_id' => $application->id]);
$slice = ServiceSlice::factory()->create([
'service_id' => $service->id,
'environment_id' => $environment->id,
]);
expect($slice->service->is($service))->toBeTrue();
expect($slice->environment->is($environment))->toBeTrue();
expect($slice->attachments)->toBeEmpty();
expect($slice->operations)->toBeEmpty();
});
it('exposes registry and source provider relations', function () {
$organisation = Organisation::factory()->create();
$registry = Registry::create([
'organisation_id' => $organisation->id,
'name' => 'gh',
'type' => 'ghcr',
]);
$sourceProvider = SourceProvider::create([
'organisation_id' => $organisation->id,
'name' => 'GitHub',
'type' => 'github',
]);
expect($registry->organisation->is($organisation))->toBeTrue();
expect($sourceProvider->organisation->is($organisation))->toBeTrue();
});
it('exposes organisation relations and slug helper', function () {
$owner = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $owner->id]);
expect($organisation->owner->is($owner))->toBeTrue();
expect($organisation->members)->toBeEmpty();
expect($organisation->services)->toBeEmpty();
expect($organisation->applications)->toBeEmpty();
expect($organisation->registries)->toBeEmpty();
expect($organisation->sourceProviders)->toBeEmpty();
expect($organisation->providers)->toBeEmpty();
expect($organisation->networks)->toBeEmpty();
expect($organisation->servers)->toBeEmpty();
Organisation::factory()->create(['slug' => 'duplicate']);
expect(Organisation::createUniqueSlug('duplicate'))->toBe('duplicate-2');
});
it('returns a HetznerService instance for a hetzner provider', function () {
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create(['type' => 'hetzner']);
expect($provider->service())->toBeInstanceOf(\App\Services\ServerProviders\HetznerService::class);
expect($provider->networks)->toBeEmpty();
expect($provider->servers)->toBeEmpty();
});
it('exposes application, service, build artifact and attachment relations', function () {
['organisation' => $organisation, 'server' => $server] = buildOrgServer();
$application = Application::factory()->create(['organisation_id' => $organisation->id]);
$environment = Environment::factory()->create(['application_id' => $application->id]);
$service = Service::factory()->for($server)->create([
'organisation_id' => $organisation->id,
'environment_id' => $environment->id,
]);
expect($application->organisation->is($organisation))->toBeTrue();
expect($application->environments)->toHaveCount(1);
expect($application->operations)->toBeEmpty();
expect($service->organisation->is($organisation))->toBeTrue();
expect($service->environment->is($environment))->toBeTrue();
expect($service->endpoints)->toBeEmpty();
expect($service->folder_name)->toBe($service->name.'-'.$service->id);
$artifact = \App\Models\BuildArtifact::create([
'environment_id' => $environment->id,
'built_by_service_id' => $service->id,
'commit_sha' => 'abc1234',
'image_tag' => 'web:abc1234',
'status' => 'pending',
]);
expect($artifact->environment->is($environment))->toBeTrue();
expect($artifact->builtByService->is($service))->toBeTrue();
expect($artifact->builtByOperation)->toBeNull();
$slice = ServiceSlice::factory()->create([
'service_id' => $service->id,
'environment_id' => $environment->id,
]);
$attachment = EnvironmentAttachment::create([
'environment_id' => $environment->id,
'service_id' => $service->id,
'service_slice_id' => $slice->id,
'role' => 'database',
'is_primary' => true,
]);
expect($attachment->environment->is($environment))->toBeTrue();
expect($attachment->service->is($service))->toBeTrue();
expect($attachment->serviceSlice->is($slice))->toBeTrue();
});
it('throws when a service references an unknown driver class', function () {
['organisation' => $organisation, 'server' => $server] = buildOrgServer();
$service = Service::factory()->for($server)->create([
'organisation_id' => $organisation->id,
'driver_name' => 'unknown.driver',
]);
expect(fn () => $service->driver())->toThrow(Exception::class, 'Driver class');
});
it('extracts logs and captures runtime state on an operation step', function () {
['organisation' => $organisation] = buildOrgServer();
$application = Application::factory()->create(['organisation_id' => $organisation->id]);
$environment = Environment::factory()->create(['application_id' => $application->id]);
$operation = Operation::factory()->create([
'target_id' => $environment->id,
'target_type' => Environment::class,
]);
$step = OperationStep::create([
'operation_id' => $operation->id,
'name' => 'render-compose',
'order' => 1,
'status' => 'completed',
'script' => 'echo [!FOO!] && echo [!BAR!]',
'secrets' => ['FOO' => 'foo-secret', 'BAR' => 'bar-secret'],
'logs' => "line one\ncontainer_id=abc123\nhealth_status=healthy",
'error_logs' => "warn\nerror message",
]);
expect($step->operation->is($operation))->toBeTrue();
expect($step->logs_excerpt)->toBe('health_status=healthy');
expect($step->error_logs_excerpt)->toBe('error message');
expect($step->scriptForExecution())->toBe('echo foo-secret && echo bar-secret');
expect($step->capturedRuntimeState())->toBe([
'container_id' => 'abc123',
'health_status' => 'healthy',
]);
Illuminate\Support\Facades\Queue::fake();
$step->dispatchJob();
Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\Services\RunStep::class);
});

View File

@@ -0,0 +1,113 @@
<?php
use App\Actions\FirewallRules\InstallFirewallRule;
use App\Enums\FirewallRuleStatus;
use App\Enums\FirewallRuleType;
use App\Models\FirewallRule;
use App\Models\Network;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use function Pest\Laravel\mock;
beforeEach(function () {
mock(InstallFirewallRule::class)->shouldReceive('execute')->andReturnNull();
});
function makeFirewallServer(): Server
{
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = Network::create([
'organisation_id' => $organisation->id,
'provider_id' => $provider->id,
'name' => 'test-network',
'ip_range' => '10.0.0.0/24',
]);
return Server::factory()
->forOrganisation($organisation->id)
->forProvider($provider->id)
->forNetwork((string) $network->id)
->create();
}
it('builds a ufw allow command for an allow rule', function () {
$rule = new FirewallRule([
'type' => FirewallRuleType::ALLOW,
'ports' => '22',
]);
expect($rule->command())->toBe('ufw allow 22');
});
it('builds a ufw deny command for a deny rule', function () {
$rule = new FirewallRule([
'type' => FirewallRuleType::DENY,
'ports' => '80',
]);
expect($rule->command())->toBe('ufw deny 80');
});
it('includes the source address when from is set', function () {
$rule = new FirewallRule([
'type' => FirewallRuleType::ALLOW,
'ports' => '5432',
'from' => '10.0.0.0/24',
]);
expect($rule->command())->toBe('ufw allow from 10.0.0.0/24 to any port 5432');
});
it('prefixes the command with delete when requested', function () {
$rule = new FirewallRule([
'type' => FirewallRuleType::ALLOW,
'ports' => '22',
]);
expect($rule->command(delete: true))->toBe('ufw delete allow 22');
});
it('casts status and type to enums', function () {
$server = makeFirewallServer();
$rule = FirewallRule::create([
'server_id' => $server->id,
'type' => 'allow',
'ports' => '22',
'status' => FirewallRuleStatus::NOT_INSTALLED->value,
]);
$rule->refresh();
expect($rule->type)->toBe(FirewallRuleType::ALLOW);
expect($rule->status)->toBe(FirewallRuleStatus::NOT_INSTALLED);
});
it('belongs to a server', function () {
$server = makeFirewallServer();
$rule = FirewallRule::create([
'server_id' => $server->id,
'type' => 'allow',
'ports' => '22',
]);
expect($rule->server)->toBeInstanceOf(Server::class);
expect($rule->server->id)->toBe($server->id);
});
it('invokes the install action when a rule is created', function () {
$action = mock(InstallFirewallRule::class);
$action->shouldReceive('execute')->once();
$server = makeFirewallServer();
FirewallRule::create([
'server_id' => $server->id,
'type' => 'allow',
'ports' => '22',
]);
});

View File

@@ -0,0 +1,66 @@
<?php
use App\Models\Application;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
use function Pest\Laravel\actingAs;
beforeEach(function () {
actingAs(User::factory()->create());
});
it('shows the onboarding page with all steps incomplete', function () {
$organisation = Organisation::factory()->create();
$response = $this->get(route('onboarding.show', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('onboarding/Show', false)
->where('nextStep.key', 'provider'));
});
it('advances the next step once a provider exists', function () {
$organisation = Organisation::factory()->create();
Provider::factory()->forOrganisation($organisation)->create();
$response = $this->get(route('onboarding.show', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->where('nextStep.key', 'source'));
});
it('falls back to the last step when everything is complete', function () {
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create();
$organisation->sourceProviders()->create([
'name' => 'GitHub',
'type' => 'github',
]);
$organisation->registries()->create([
'name' => 'gh',
'type' => 'ghcr',
'url' => 'ghcr.io',
]);
Application::factory()->create(['organisation_id' => $organisation->id]);
$network = $organisation->networks()->create([
'name' => 'keystone',
'provider_id' => $provider->id,
'ip_range' => '10.0.0.0/24',
]);
\App\Models\Server::factory()
->forOrganisation($organisation->id)
->forProvider($provider->id)
->forNetwork((string) $network->id)
->create();
$response = $this->get(route('onboarding.show', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->where('nextStep.key', 'application'));
});

View File

@@ -0,0 +1,28 @@
<?php
use App\Models\Organisation;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
use function Pest\Laravel\actingAs;
beforeEach(function () {
actingAs(User::factory()->create());
});
it('renders the organisation page with counts', function () {
$organisation = Organisation::factory()->create();
$response = $this->get(route('organisations.show', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('organisations/Show', false)
->where('organisation.id', $organisation->id));
});
it('404s for a non-existent organisation', function () {
$response = $this->get(route('organisations.show', ['organisation' => 999999]));
$response->assertNotFound();
});

View File

@@ -0,0 +1,90 @@
<?php
use App\Enums\ServerStatus;
use App\Events\Servers\ServerProvisioned;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use Illuminate\Support\Facades\Event;
function provisioningServer(): Server
{
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'name' => 'keystone',
'provider_id' => $provider->id,
'ip_range' => '10.0.0.0/24',
]);
return Server::factory()
->forOrganisation($organisation->id)
->forProvider($provider->id)
->forNetwork((string) $network->id)
->create([
'ipv4' => '203.0.113.4',
'ipv6' => '2001:db8::1',
'status' => ServerStatus::PROVISIONING,
]);
}
it('rejects callbacks from an unknown source ip', function () {
Event::fake([ServerProvisioned::class]);
$server = provisioningServer();
$response = $this->postJson(route('provision.callback'), [
'server_id' => $server->id,
], ['REMOTE_ADDR' => '198.51.100.9']);
$response->assertUnauthorized();
Event::assertNotDispatched(ServerProvisioned::class);
expect($server->fresh()->status)->toBe(ServerStatus::PROVISIONING);
});
it('marks the server active and dispatches an event when the source ip matches ipv4', function () {
Event::fake([ServerProvisioned::class]);
$server = provisioningServer();
$response = $this->call(
'POST',
route('provision.callback'),
['server_id' => $server->id],
[],
[],
['REMOTE_ADDR' => '203.0.113.4'],
);
$response->assertOk();
Event::assertDispatched(ServerProvisioned::class);
expect($server->fresh()->status)->toBe(ServerStatus::ACTIVE);
});
it('marks the server active when the source ip matches ipv6', function () {
Event::fake([ServerProvisioned::class]);
$server = provisioningServer();
$response = $this->call(
'POST',
route('provision.callback'),
['server_id' => $server->id],
[],
[],
['REMOTE_ADDR' => '2001:db8::1'],
);
$response->assertOk();
expect($server->fresh()->status)->toBe(ServerStatus::ACTIVE);
});
it('validates that the server id exists', function () {
$response = $this->postJson(route('provision.callback'), [
'server_id' => 999999,
]);
$response->assertUnprocessable();
});

View File

@@ -146,3 +146,76 @@ test('show route displays a single server', function () {
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Show', false));
});
test('create route fetches and caches locations, server types and images when provider param is given', function () {
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create([
'type' => ProviderType::HETZNER,
]);
$this->partialMock(HetznerService::class, function (MockInterface $mock) {
$mock->shouldReceive('forProvider')->andReturnSelf();
$mock->shouldReceive('getLocations')->andReturn(collect());
$mock->shouldReceive('getServerTypes')->andReturn(collect());
$mock->shouldReceive('getImages')->andReturn(collect());
});
$response = $this->get(route('servers.create', [
'organisation' => $organisation->id,
'provider' => $provider->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Create', false)
->has('locations')
->has('serverTypes')
->has('images'));
});
test('store route creates a network when none exists yet for the provider', function () {
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create([
'type' => ProviderType::HETZNER,
]);
$this->partialMock(HetznerService::class, function (MockInterface $mock) {
$mock->shouldReceive('forProvider')->andReturnSelf();
$mock->shouldReceive('createNetwork')
->once()
->andReturn(new \App\Data\ServerProviders\Network(
id: 'net-99',
name: 'keystone-global',
ipRange: '10.0.0.0/24',
networkZone: 'global',
));
$mock->shouldReceive('createServer')
->once()
->andReturn(new CreatedServer(
name: 'fresh-server',
rootPassword: 'pw',
id: 'srv-1',
status: 'running',
ipv4: '203.0.113.10',
ipv6: '2001:db8::10',
networkId: 'net-99',
privateIp: '10.0.0.10',
));
});
$response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [
'provider' => $provider->id,
'server_type' => 'cx11',
'location' => 'hel1',
'image' => 'ubuntu-22.04',
]);
$response->assertRedirectContains('/servers/');
$this->assertDatabaseHas('networks', [
'provider_id' => $provider->id,
'external_id' => 'net-99',
'name' => 'keystone-global',
]);
});

View File

@@ -18,6 +18,8 @@ use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Config;
use Inertia\Testing\AssertableInertia;
use function Pest\Laravel\actingAs;
uses(RefreshDatabase::class);
function setupTestEnvironment()
@@ -347,3 +349,77 @@ test('store service is properly created and dispatched', function () {
Bus::assertNotDispatched(DeployService::class);
});
test('show service page renders inertia view', function () {
$setup = setupTestEnvironment();
actingAs($setup['user']);
$service = Service::factory()->create([
'server_id' => $setup['server']->id,
'organisation_id' => $setup['organisation']->id,
]);
$response = $this->get(route('services.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $service->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('services/Show', false));
});
test('edit service page renders inertia view', function () {
$setup = setupTestEnvironment();
actingAs($setup['user']);
$service = Service::factory()->create([
'server_id' => $setup['server']->id,
'organisation_id' => $setup['organisation']->id,
]);
$response = $this->get(route('services.edit', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $service->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('services/Edit', false));
});
test('update service persists changes and redirects', function () {
$setup = setupTestEnvironment();
actingAs($setup['user']);
$service = Service::factory()->create([
'server_id' => $setup['server']->id,
'organisation_id' => $setup['organisation']->id,
'name' => 'web',
'desired_replicas' => 1,
]);
$response = $this->put(route('services.update', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $service->id,
]), [
'name' => 'web-renamed',
'desired_replicas' => 4,
'default_cpu_limit' => 2,
'default_memory_limit_mb' => 1024,
]);
$response->assertRedirect(route('services.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $service->id,
]));
$response->assertSessionHas('success', 'Service updated.');
$service->refresh();
expect($service->name)->toBe('web-renamed');
expect($service->desired_replicas)->toBe(4);
});

View File

@@ -66,6 +66,78 @@ it('pulls the image before failing digest resolution when it is not present loca
->and($runner->script)->toContain('docker pull "$image"');
});
it('short circuits when the resolved image is already a sha256 digest', function () {
$service = Service::factory()->for(serviceDigestServer())->create([
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
'credentials' => [
'user' => 'keystone',
'password' => 'secret',
'db' => 'keystone',
],
'available_image_digest' => 'sha256:precomputed',
]);
expect(app(ResolveServiceImageDigest::class)->execute($service))->toBe('sha256:precomputed');
});
it('falls back to the raw output when the digest line is not present', function () {
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
{
public function run(Server $server, string $script): string
{
return 'postgres:18@sha256:fallbackdigest';
}
});
$service = Service::factory()->for(serviceDigestServer())->create([
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
'credentials' => ['user' => 'u', 'password' => 'p', 'db' => 'd'],
]);
expect(app(ResolveServiceImageDigest::class)->execute($service))->toBe('sha256:fallbackdigest');
});
it('throws when the service has no server to query', function () {
$service = Service::factory()->create([
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'driver_name' => 'postgres.18',
'credentials' => ['user' => 'u', 'password' => 'p', 'db' => 'd'],
'server_id' => null,
]);
expect(fn () => app(ResolveServiceImageDigest::class)->execute($service))
->toThrow(RuntimeException::class, 'must have a target server');
});
it('throws when the remote output does not yield a digest', function () {
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
{
public function run(Server $server, string $script): string
{
return 'no digest at all';
}
});
$service = Service::factory()->for(serviceDigestServer())->create([
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'driver_name' => 'postgres.18',
'credentials' => ['user' => 'u', 'password' => 'p', 'db' => 'd'],
]);
expect(fn () => app(ResolveServiceImageDigest::class)->execute($service))
->toThrow(RuntimeException::class, 'Unable to resolve image digest');
});
function serviceDigestServer(): Server
{
$organisation = Organisation::factory()->create();

View File

@@ -0,0 +1,62 @@
<?php
use App\Http\Requests\UpdateServiceRequest;
use Illuminate\Support\Facades\Validator;
function validateUpdate(array $payload): array
{
$request = new UpdateServiceRequest;
return Validator::make($payload, $request->rules())->errors()->toArray();
}
it('requires a name and desired_replicas', function () {
$errors = validateUpdate([]);
expect($errors)->toHaveKey('name');
expect($errors)->toHaveKey('desired_replicas');
});
it('rejects desired_replicas above the cap', function () {
$errors = validateUpdate([
'name' => 'web',
'desired_replicas' => 26,
]);
expect($errors)->toHaveKey('desired_replicas');
});
it('rejects a default cpu limit above the cap', function () {
$errors = validateUpdate([
'name' => 'web',
'desired_replicas' => 1,
'default_cpu_limit' => 65,
]);
expect($errors)->toHaveKey('default_cpu_limit');
});
it('rejects a default memory limit below the floor', function () {
$errors = validateUpdate([
'name' => 'web',
'desired_replicas' => 1,
'default_memory_limit_mb' => 32,
]);
expect($errors)->toHaveKey('default_memory_limit_mb');
});
it('accepts a valid payload', function () {
$errors = validateUpdate([
'name' => 'web',
'desired_replicas' => 3,
'default_cpu_limit' => 1.5,
'default_memory_limit_mb' => 512,
]);
expect($errors)->toBeEmpty();
});
it('authorises the request', function () {
expect((new UpdateServiceRequest)->authorize())->toBeTrue();
});

View File

@@ -0,0 +1,23 @@
<?php
use App\Enums\ServiceCategory;
it('returns a description for every category', function (ServiceCategory $category) {
expect(ServiceCategory::getDescription($category))->toBeString()->not->toBeEmpty();
})->with([
ServiceCategory::APPLICATION,
ServiceCategory::DATABASE,
ServiceCategory::GATEWAY,
ServiceCategory::STORAGE,
ServiceCategory::CACHE,
ServiceCategory::BUILDER,
]);
it('accepts a string and resolves it to the enum description', function () {
expect(ServiceCategory::getDescription('database'))->toBe('Postgres');
});
it('rejects invalid values via the string overload', function () {
expect(fn () => ServiceCategory::getDescription('not-a-category'))
->toThrow(ValueError::class);
});

View File

@@ -0,0 +1,37 @@
<?php
use App\Support\Ip;
it('matches an ip exactly when no mask is provided', function () {
expect(Ip::inNetwork('10.0.0.1', '10.0.0.1'))->toBeTrue();
});
it('returns false when an ip does not equal a non-cidr value', function () {
expect(Ip::inNetwork('10.0.0.1', '10.0.0.2'))->toBeFalse();
});
it('matches an ipv4 inside a byte-aligned cidr', function () {
expect(Ip::inNetwork('10.0.0.42', '10.0.0.0/24'))->toBeTrue();
});
it('rejects an ipv4 outside a byte-aligned cidr', function () {
expect(Ip::inNetwork('10.0.1.42', '10.0.0.0/24'))->toBeFalse();
});
it('matches an ipv4 inside a non-byte-aligned cidr', function () {
expect(Ip::inNetwork('192.168.16.5', '192.168.16.0/20'))->toBeTrue();
});
it('rejects an ipv4 outside a non-byte-aligned cidr', function () {
expect(Ip::inNetwork('192.168.32.5', '192.168.16.0/20'))->toBeFalse();
});
it('matches a /32 host route', function () {
expect(Ip::inNetwork('10.0.0.1', '10.0.0.1/32'))->toBeTrue();
expect(Ip::inNetwork('10.0.0.2', '10.0.0.1/32'))->toBeFalse();
});
it('matches every address inside a /0', function () {
expect(Ip::inNetwork('10.0.0.1', '0.0.0.0/0'))->toBeTrue();
expect(Ip::inNetwork('255.255.255.255', '0.0.0.0/0'))->toBeTrue();
});