Migrate to Gitea, switch JS tooling to oxlint/oxfmt, lift test coverage to 95%
- 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:
30
tests/Feature/Auth/EmailVerificationNotificationTest.php
Normal file
30
tests/Feature/Auth/EmailVerificationNotificationTest.php
Normal 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));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
74
tests/Feature/AuthFlowsTest.php
Normal file
74
tests/Feature/AuthFlowsTest.php
Normal 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();
|
||||
});
|
||||
65
tests/Feature/Drivers/Caddy2DriverTest.php
Normal file
65
tests/Feature/Drivers/Caddy2DriverTest.php
Normal 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');
|
||||
});
|
||||
91
tests/Feature/Drivers/LaravelRuntimeDriverTest.php
Normal file
91
tests/Feature/Drivers/LaravelRuntimeDriverTest.php
Normal 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');
|
||||
});
|
||||
110
tests/Feature/Drivers/Valkey8DriverTest.php
Normal file
110
tests/Feature/Drivers/Valkey8DriverTest.php
Normal 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([]);
|
||||
});
|
||||
55
tests/Feature/EnvironmentControllerTest.php
Normal file
55
tests/Feature/EnvironmentControllerTest.php
Normal 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();
|
||||
});
|
||||
11
tests/Feature/Events/ServerProvisionedTest.php
Normal file
11
tests/Feature/Events/ServerProvisionedTest.php
Normal 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([]);
|
||||
});
|
||||
258
tests/Feature/ModelRelationshipsTest.php
Normal file
258
tests/Feature/ModelRelationshipsTest.php
Normal 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);
|
||||
});
|
||||
113
tests/Feature/Models/FirewallRuleTest.php
Normal file
113
tests/Feature/Models/FirewallRuleTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
66
tests/Feature/OnboardingControllerTest.php
Normal file
66
tests/Feature/OnboardingControllerTest.php
Normal 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'));
|
||||
});
|
||||
28
tests/Feature/OrganisationControllerTest.php
Normal file
28
tests/Feature/OrganisationControllerTest.php
Normal 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();
|
||||
});
|
||||
90
tests/Feature/ProvisionCallbackTest.php
Normal file
90
tests/Feature/ProvisionCallbackTest.php
Normal 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();
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
62
tests/Feature/UpdateServiceRequestTest.php
Normal file
62
tests/Feature/UpdateServiceRequestTest.php
Normal 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();
|
||||
});
|
||||
23
tests/Unit/Enums/ServiceCategoryTest.php
Normal file
23
tests/Unit/Enums/ServiceCategoryTest.php
Normal 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);
|
||||
});
|
||||
37
tests/Unit/Support/IpTest.php
Normal file
37
tests/Unit/Support/IpTest.php
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user