wowowowowo
Some checks failed
CI / Lint (push) Failing after 22s
CI / Tests (push) Failing after 33s

This commit is contained in:
2026-05-28 15:15:41 +01:00
parent 8f603122e2
commit 5b977c1f41
129 changed files with 9943 additions and 722 deletions

View File

@@ -1,9 +1,13 @@
<?php
use App\Actions\Applications\GenerateDeployKey;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\RepositoryType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\SourceProvider;
use App\Models\User;
use Illuminate\Support\Facades\Process;
use Inertia\Testing\AssertableInertia;
@@ -31,7 +35,14 @@ it('shows an application with environments services and attachments', function (
$environment = Environment::factory()->for($application)->create(['name' => 'production']);
$environment->services()->create(\App\Models\Service::factory()->make([
'organisation_id' => $organisation->id,
'desired_revision' => 'abc123',
'current_image_digest' => 'sha256:current',
])->toArray());
$environment->operations()->create([
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
'status' => OperationStatus::COMPLETED,
'finished_at' => now(),
]);
$response = $this->actingAs($user)->get(route('applications.show', [
'organisation' => $organisation->id,
@@ -42,7 +53,10 @@ it('shows an application with environments services and attachments', function (
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('applications/Show', false)
->has('application.environments', 1)
->has('application.environments.0.services', 1));
->has('application.environments.0.services', 1)
->where('application.environments.0.services.0.desired_revision', 'abc123')
->where('application.environments.0.services.0.current_image_digest', 'sha256:current')
->has('application.environments.0.operations', 1));
});
it('shows the create application page', function () {
@@ -73,11 +87,19 @@ it('stores an application with a deploy key and default laravel environment', fu
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$sourceProvider = SourceProvider::query()->create([
'organisation_id' => $organisation->id,
'name' => 'GitHub',
'type' => 'github',
'url' => 'https://github.com',
]);
$response = $this->actingAs($user)->post(route('applications.store', [
'organisation' => $organisation->id,
]), [
'name' => 'Billing API',
'source_provider_id' => $sourceProvider->id,
'repository_type' => RepositoryType::GIT->value,
'repository_url' => 'git@example.com:org/billing-api.git',
'default_branch' => 'main',
'environment_name' => 'production',
@@ -91,10 +113,27 @@ it('stores an application with a deploy key and default laravel environment', fu
]));
expect($application->deploy_key_public)->toStartWith('ssh-ed25519')
->and($application->source_provider_id)->toBe($sourceProvider->id)
->and($application->repository_type)->toBe(RepositoryType::GIT)
->and($application->environments()->where('name', 'production')->exists())->toBeTrue()
->and($application->environments()->first()->services()->where('name', 'web')->exists())->toBeTrue();
});
it('validates application repository type', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$this->actingAs($user)->post(route('applications.store', [
'organisation' => $organisation->id,
]), [
'name' => 'Billing API',
'repository_type' => 'svn',
'repository_url' => 'git@example.com:org/billing-api.git',
'default_branch' => 'main',
'environment_name' => 'production',
])->assertInvalid(['repository_type']);
});
it('verifies repository access for an application deploy key', function () {
Process::fake([
'*' => Process::result(output: "abc123\trefs/heads/main\n"),
@@ -120,3 +159,34 @@ it('verifies repository access for an application deploy key', function () {
$response->assertRedirect();
expect($application->refresh()->deploy_key_installed_at)->not->toBeNull();
});
it('rotates an application deploy key and clears verification state', function () {
$this->app->bind(GenerateDeployKey::class, fn () => new class extends GenerateDeployKey
{
public function execute(Application $application, ?array $keyPair = null): Application
{
return parent::execute($application, [
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIRotatedPublicKey keystone',
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\nrotated\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => 'SHA256:rotated',
]);
}
});
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create([
'deploy_key_public' => 'old-public-key',
'deploy_key_private' => 'old-private-key',
'deploy_key_fingerprint' => 'SHA256:old',
'deploy_key_installed_at' => now(),
]);
$this->actingAs($user)
->post(route('applications.deploy-key.rotate', [$organisation, $application]))
->assertRedirect();
expect($application->refresh()->deploy_key_public)->toContain('IRotatedPublicKey')
->and($application->deploy_key_fingerprint)->toBe('SHA256:rotated')
->and($application->deploy_key_installed_at)->toBeNull();
});

View File

@@ -0,0 +1,199 @@
<?php
use App\Enums\BuildStrategy;
use App\Enums\DeployPolicy;
use App\Enums\RegistryType;
use App\Enums\RepositoryType;
use App\Enums\SchedulerMode;
use App\Enums\SourceProviderType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Service;
use App\Models\SourceProvider;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('updates and deletes applications through UI routes', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$sourceProvider = SourceProvider::query()->create([
'organisation_id' => $organisation->id,
'name' => 'GitHub',
'type' => SourceProviderType::GITHUB,
'url' => 'https://github.com',
]);
$application = Application::factory()->for($organisation)->create();
$this->actingAs($user)
->get(route('applications.edit', [$organisation, $application]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->component('applications/Edit', false));
$this->actingAs($user)->put(route('applications.update', [$organisation, $application]), [
'name' => 'Renamed API',
'source_provider_id' => $sourceProvider->id,
'repository_type' => RepositoryType::GIT->value,
'repository_url' => 'git@example.com:org/renamed.git',
'default_branch' => 'release',
])->assertRedirect(route('applications.show', [$organisation, $application]));
expect($application->refresh()->name)->toBe('Renamed API')
->and($application->source_provider_id)->toBe($sourceProvider->id)
->and($application->repository_type)->toBe(RepositoryType::GIT);
$this->actingAs($user)
->delete(route('applications.destroy', [$organisation, $application]))
->assertRedirect(route('applications.index', $organisation));
expect(Application::query()->whereKey($application->id)->exists())->toBeFalse();
});
it('creates updates and deletes environments through UI routes', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create(['default_branch' => 'main']);
$this->actingAs($user)
->post(route('environments.store', [$organisation, $application]), [
'name' => 'staging',
'branch' => 'develop',
'php_version' => '8.4',
])
->assertRedirect();
$environment = $application->environments()->where('name', 'staging')->firstOrFail();
$service = $environment->services()->firstOrFail();
$this->actingAs($user)
->get(route('environments.edit', [$organisation, $application, $environment]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->component('environments/Edit', false));
$this->actingAs($user)
->put(route('environments.update', [$organisation, $application, $environment]), [
'name' => 'preview',
'branch' => 'preview',
'status' => 'active',
'scheduler_enabled' => true,
'scheduler_target_service_id' => $service->id,
'scheduler_mode' => SchedulerMode::EVERY_REPLICA->value,
'build_strategy' => BuildStrategy::DEDICATED_BUILDER->value,
'php_version' => '8.4',
'document_root' => 'public',
'health_path' => '/health',
'js_package_manager' => 'bun',
'js_build_command' => 'bun run build',
])
->assertRedirect(route('environments.show', [$organisation, $application, $environment]));
expect($environment->refresh()->name)->toBe('preview')
->and($environment->scheduler_mode)->toBe(SchedulerMode::EVERY_REPLICA)
->and($environment->build_config['build_strategy'])->toBe(BuildStrategy::DEDICATED_BUILDER->value);
$this->actingAs($user)
->delete(route('environments.destroy', [$organisation, $application, $environment]))
->assertRedirect(route('applications.show', [$organisation, $application]));
expect(Environment::query()->whereKey($environment->id)->exists())->toBeFalse();
});
it('updates and deletes registries and source providers', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$registry = $organisation->registries()->create([
'name' => 'Old Registry',
'type' => RegistryType::GENERIC,
'url' => 'registry.example.com',
'credentials' => ['username' => 'old', 'password' => 'secret'],
]);
$sourceProvider = $organisation->sourceProviders()->create([
'name' => 'Old Git',
'type' => SourceProviderType::GENERIC_GIT,
'url' => 'https://git.example.com',
'config' => [],
]);
$this->actingAs($user)
->get(route('source-providers.index', $organisation))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('source-providers/Index', false)
->has('sourceProviders', 1)
->where('sourceProviders.0.name', 'Old Git'));
$this->actingAs($user)
->put(route('registries.update', [$organisation, $registry]), [
'name' => 'Registry',
'type' => RegistryType::GHCR->value,
'url' => 'ghcr.io/example',
'username' => 'new',
'password' => '',
])
->assertRedirect(route('organisations.show', $organisation));
$this->actingAs($user)
->put(route('source-providers.update', [
'organisation' => $organisation,
'source_provider' => $sourceProvider,
]), [
'name' => 'GitHub',
'type' => SourceProviderType::GITHUB->value,
'url' => 'https://github.com',
])
->assertRedirect(route('organisations.show', $organisation));
expect($registry->refresh()->name)->toBe('Registry')
->and($sourceProvider->refresh()->name)->toBe('GitHub');
$this->actingAs($user)->delete(route('registries.destroy', [$organisation, $registry]))
->assertRedirect(route('organisations.show', $organisation));
$this->actingAs($user)->delete(route('source-providers.destroy', [
'organisation' => $organisation,
'source_provider' => $sourceProvider,
]))->assertRedirect(route('organisations.show', $organisation));
expect($organisation->registries()->exists())->toBeFalse()
->and($organisation->sourceProviders()->exists())->toBeFalse();
});
it('updates service deployment and migration settings', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'provider_id' => $provider->id,
'external_id' => 'network-1',
'network_zone' => 'global',
'name' => 'keystone-global',
'ip_range' => '10.0.0.0/16',
]);
$server = \App\Models\Server::factory()
->forOrganisation($organisation->id)
->forProvider((string) $provider->id)
->forNetwork((string) $network->id)
->create();
$service = Service::factory()->for($organisation)->for($server)->create();
$this->actingAs($user)
->put(route('services.update', [$organisation, $server, $service]), [
'name' => 'postgres',
'desired_replicas' => 2,
'default_cpu_limit' => 1,
'default_memory_limit_mb' => 512,
'deploy_policy' => DeployPolicy::MANUAL->value,
'version_track' => '18',
'available_image_digest' => 'sha256:test',
'process_roles' => 'database, scheduler',
'migration_mode' => 'manual',
'migration_timing' => 'post_switch',
'migration_command' => 'php artisan migrate --force',
'health_path' => '/up',
])
->assertRedirect(route('services.show', [$organisation, $server, $service]));
expect($service->refresh()->deploy_policy)->toBe(DeployPolicy::MANUAL)
->and($service->process_roles)->toBe(['database', 'scheduler'])
->and($service->config['health_path'])->toBe('/up');
});

View File

@@ -0,0 +1,7 @@
<?php
test('example', function () {
$response = $this->get('/');
$response->assertStatus(200);
});

View File

@@ -213,6 +213,7 @@ it('creates replica route configure and gateway cutover child operations', funct
->and($parent->children()->where('kind', OperationKind::GATEWAY_CUTOVER)->first()->steps()->pluck('name')->all())
->toBe([
'Validate Caddy route configuration',
'Check TLS certificate status',
'Reload Caddy',
'Verify new upstreams are reachable',
'Drain old upstreams',

View File

@@ -74,3 +74,153 @@ it('stores a managed attachment and generated environment variables', function (
->and($environment->variables()->where('key', 'DB_CONNECTION')->first()->value)->toBe('pgsql')
->and($service->slices()->where('name', 'billing_api')->exists())->toBeTrue();
});
it('stores gateway route configuration on caddy attachments', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create(['name' => 'Billing API']);
$environment = Environment::factory()->for($application)->create(['name' => 'production']);
$service = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'gateway',
'category' => ServiceCategory::GATEWAY,
'type' => ServiceType::CADDY,
'version' => '2',
'version_track' => '2',
'driver_name' => 'caddy.2',
]);
$this->actingAs($user)->post(route('environment-attachments.store', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]), [
'service_id' => $service->id,
'role' => EnvironmentAttachmentRole::GATEWAY->value,
'name' => 'billing',
'domain' => 'billing.example.com',
'path_prefix' => '/app',
'tls_enabled' => true,
'is_primary' => true,
])->assertRedirect(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$slice = $service->slices()->where('type', 'route')->firstOrFail();
expect($slice->config['domain'])->toBe('billing.example.com')
->and($slice->config['path_prefix'])->toBe('/app')
->and($slice->config['tls_enabled'])->toBeTrue();
$attachment = $environment->attachments()->firstOrFail();
$this->actingAs($user)->put(route('environment-attachments.update', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
'attachment' => $attachment->id,
]), [
'role' => EnvironmentAttachmentRole::GATEWAY->value,
'env_prefix' => null,
'is_primary' => true,
'domain' => 'www.example.com',
'path_prefix' => '/',
'tls_enabled' => false,
'certificate_status' => 'disabled',
])->assertRedirect(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
expect($slice->refresh()->config)->toMatchArray([
'domain' => 'www.example.com',
'path_prefix' => '/',
'tls_enabled' => false,
'certificate_status' => 'disabled',
]);
});
it('manages gateway routes through dedicated route pages', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create(['name' => 'Billing API']);
$environment = Environment::factory()->for($application)->create(['name' => 'production']);
$service = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'gateway',
'category' => ServiceCategory::GATEWAY,
'type' => ServiceType::CADDY,
'version' => '2',
'version_track' => '2',
'driver_name' => 'caddy.2',
]);
$this->actingAs($user)
->get(route('gateway.routes.create', [$organisation, $application, $environment]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('gateway-routes/Create', false)
->has('services', 1)
->where('services.0.id', $service->id));
$this->actingAs($user)
->post(route('gateway.routes.store', [$organisation, $application, $environment]), [
'service_id' => $service->id,
'name' => 'billing',
'domain' => 'billing.example.com',
'path_prefix' => '/app',
'tls_enabled' => true,
])
->assertRedirect(route('gateway.routes.index', [$organisation, $application, $environment]));
$attachment = $environment->attachments()->with('serviceSlice')->firstOrFail();
expect($attachment->role)->toBe(EnvironmentAttachmentRole::GATEWAY)
->and($attachment->serviceSlice->config)->toMatchArray([
'domain' => 'billing.example.com',
'path_prefix' => '/app',
'tls_enabled' => true,
'certificate_status' => 'pending',
]);
$this->actingAs($user)
->get(route('gateway.routes.index', [$organisation, $application, $environment]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('gateway-routes/Index', false)
->has('routes', 1)
->where('routes.0.id', $attachment->id)
->where('routes.0.service_slice.config.domain', 'billing.example.com'));
$this->actingAs($user)
->get(route('gateway.routes.edit', [$organisation, $application, $environment, $attachment]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('gateway-routes/Edit', false)
->where('routeAttachment.id', $attachment->id));
$this->actingAs($user)
->put(route('gateway.routes.update', [$organisation, $application, $environment, $attachment]), [
'domain' => 'www.example.com',
'path_prefix' => '/',
'tls_enabled' => false,
'certificate_status' => 'disabled',
])
->assertRedirect(route('gateway.routes.index', [$organisation, $application, $environment]));
expect($attachment->serviceSlice->refresh()->config)->toMatchArray([
'domain' => 'www.example.com',
'path_prefix' => '/',
'tls_enabled' => false,
'certificate_status' => 'disabled',
]);
$this->actingAs($user)
->delete(route('gateway.routes.destroy', [$organisation, $application, $environment, $attachment]))
->assertRedirect(route('gateway.routes.index', [$organisation, $application, $environment]));
expect($environment->attachments()->exists())->toBeFalse();
});

View File

@@ -1,8 +1,17 @@
<?php
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Network;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Models\ServiceSlice;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
@@ -53,3 +62,77 @@ it('404s when the environment does not belong to the application', function () {
$response->assertNotFound();
});
it('renders caddyfile previews for gateway attachments', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->create([
'organisation_id' => $organisation->id,
]);
$environment = Environment::factory()->create([
'application_id' => $application->id,
]);
$gateway = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'gateway',
'category' => ServiceCategory::GATEWAY,
'type' => ServiceType::CADDY,
'version' => '2',
'version_track' => '2',
'driver_name' => 'caddy.2',
]);
$web = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'web',
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'version' => 'php-8.4',
'version_track' => 'php-8.4',
'driver_name' => 'laravel.php-8.4',
'process_roles' => ['web'],
]);
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = Network::query()->create([
'organisation_id' => $organisation->id,
'provider_id' => $provider->id,
'name' => 'private',
'ip_range' => '10.0.0.0/16',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider((string) $provider->id)
->forNetwork((string) $network->id)
->create();
ServiceReplica::factory()->for($web)->for($server)->create([
'internal_host' => 'web-1',
'internal_port' => 8080,
]);
$slice = ServiceSlice::factory()->for($gateway)->for($environment)->create([
'name' => 'billing',
'type' => 'route',
'config' => [
'domain' => 'billing.example.com',
'path_prefix' => '/app',
'tls_enabled' => false,
],
]);
$attachment = $environment->attachments()->create([
'service_id' => $gateway->id,
'service_slice_id' => $slice->id,
'role' => EnvironmentAttachmentRole::GATEWAY,
'is_primary' => true,
]);
$response = $this->get(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$response->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('environments/Show', false)
->where('gatewayRoutePreviews.0.attachment_id', $attachment->id)
->where('gatewayRoutePreviews.0.caddyfile', fn (string $caddyfile): bool => str_contains($caddyfile, 'http://billing.example.com {')
&& str_contains($caddyfile, 'handle_path /app* {')
&& str_contains($caddyfile, 'reverse_proxy web-1:8080')));
});

View File

@@ -4,8 +4,10 @@ use App\Actions\Applications\GenerateDeployKey;
use App\Enums\DeployPolicy;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\RegistryType;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Jobs\Environments\DeployEnvironment;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Network;
@@ -14,8 +16,10 @@ use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Models\User;
use App\Services\Operations\RemoteCommandRunner;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
it('runs an environment deployment from the application surface', function () {
@@ -93,3 +97,134 @@ it('runs an environment deployment from the application surface', function () {
->and($service->refresh()->replicas)->toHaveCount(1)
->and($service->available_image_digest)->toBe('sha256:controllerdigest');
});
it('blocks multi-server environment deployment until a registry is configured', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$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',
]);
$primaryServer = Server::factory()
->forOrganisation($organisation->id)
->forProvider($provider->id)
->forNetwork($network->id)
->create();
$secondaryServer = Server::factory()
->forOrganisation($organisation->id)
->forProvider($provider->id)
->forNetwork($network->id)
->create();
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$service = Service::factory()->for($environment)->for($primaryServer)->create([
'organisation_id' => $organisation->id,
'category' => ServiceCategory::APPLICATION,
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
]);
ServiceReplica::factory()
->for($service)
->for($secondaryServer, 'server')
->create();
$this->actingAs($user)
->post(route('environment-deployments.store', [$organisation, $application, $environment]))
->assertRedirect()
->assertSessionHas('error', 'Configure a registry before deploying this environment to multiple servers.');
expect($environment->operations()->exists())->toBeFalse();
$organisation->registries()->create([
'name' => 'GHCR',
'type' => RegistryType::GHCR,
'url' => 'ghcr.io/example',
'credentials' => ['username' => 'keystone', 'password' => 'secret'],
]);
Bus::fake();
$this->actingAs($user)
->post(route('environment-deployments.store', [$organisation, $application, $environment]))
->assertRedirect(route('environments.show', [$organisation, $application, $environment]));
Bus::assertDispatched(DeployEnvironment::class);
});
it('deploys an environment at a specific commit when provided', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$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',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider($provider->id)
->forNetwork($network->id)
->create();
$application = Application::factory()->for($organisation)->create();
app(GenerateDeployKey::class)->execute($application, [
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => 'SHA256:test',
]);
$environment = Environment::factory()->for($application)->create();
$service = Service::factory()->for($environment)->for($server)->create([
'organisation_id' => $organisation->id,
'name' => 'web',
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'version' => 'php-8.4',
'version_track' => 'php-8.4',
'driver_name' => 'laravel.php-8.4',
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
]);
$targetCommit = str_repeat('b', 40);
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
{
public function run(Server $server, string $script): string
{
return "container_id=container-1\nhealth_status=running\nimage_digest=billing-api:bbbbbbbbbbbb@sha256:manualcommit\n";
}
});
Process::fake([
'*' => Process::result(output: str_repeat('a', 40)."\trefs/heads/main\n"),
]);
$this->actingAs($user)
->post(route('environment-deployments.store', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]), [
'target_commit' => $targetCommit,
])
->assertRedirect(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
expect($service->refresh()->desired_revision)->toBe($targetCommit)
->and($environment->buildArtifacts()->where('commit_sha', $targetCommit)->exists())->toBeTrue();
});
it('validates the optional environment deployment commit', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$this->actingAs($user)
->post(route('environment-deployments.store', [$organisation, $application, $environment]), [
'target_commit' => 'not-a-sha',
])
->assertInvalid(['target_commit']);
});

View File

@@ -39,9 +39,10 @@ it('stores editable user-defined environment variables', function () {
'value' => 'false',
]);
$response->assertRedirect(route('applications.show', [
$response->assertRedirect(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$variable = $environment->variables()->firstOrFail();
@@ -52,3 +53,67 @@ it('stores editable user-defined environment variables', function () {
->and($variable->overridable)->toBeTrue()
->and($variable->service_slice_id)->toBeNull();
});
it('lists updates and deletes environment variables', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$variable = $environment->variables()->create([
'key' => 'APP_DEBUG',
'value' => 'false',
'source' => EnvironmentVariableSource::USER,
'overridable' => true,
]);
$this->actingAs($user)
->get(route('environment-variables.index', [$organisation, $application, $environment]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('environment-variables/Index', false)
->has('variables', 1)
->where('variables.0.value', 'false'));
$this->actingAs($user)
->put(route('environment-variables.update', [$organisation, $application, $environment, $variable]), [
'key' => 'APP_ENV',
'value' => 'production',
'overridable' => false,
])
->assertRedirect(route('environment-variables.index', [$organisation, $application, $environment]));
expect($variable->refresh()->key)->toBe('APP_ENV')
->and($variable->overridable)->toBeFalse();
$this->actingAs($user)
->delete(route('environment-variables.destroy', [$organisation, $application, $environment, $variable]))
->assertRedirect(route('environment-variables.index', [$organisation, $application, $environment]));
expect($environment->variables()->exists())->toBeFalse();
});
it('bulk imports dotenv environment variables', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$this->actingAs($user)
->post(route('environment-variables.import', [$organisation, $application, $environment]), [
'contents' => implode("\n", [
'# ignored comment',
'APP_ENV=production',
'APP_NAME="Billing API"',
"export FEATURE_FLAG='enabled'",
'bad-key=ignored',
]),
'overridable' => false,
])
->assertRedirect(route('environment-variables.index', [$organisation, $application, $environment]));
expect($environment->variables()->count())->toBe(3)
->and($environment->variables()->where('key', 'APP_ENV')->first()->value)->toBe('production')
->and($environment->variables()->where('key', 'APP_NAME')->first()->value)->toBe('Billing API')
->and($environment->variables()->where('key', 'FEATURE_FLAG')->first()->value)->toBe('enabled')
->and($environment->variables()->where('key', 'APP_ENV')->first()->overridable)->toBeFalse();
});

View File

@@ -0,0 +1,53 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('shares environment first navigation context with onboarding counts', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$user->organisations()->attach($organisation, ['role' => 'admin']);
$response = $this->actingAs($user)->get(route('organisations.show', [
'organisation' => $organisation->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->where('organisation.id', $organisation->id)
->where('organisation.providers_count', 0)
->where('organisation.applications_count', 0));
});
it('lists environments across applications', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
Environment::factory()->for($application)->create(['name' => 'production']);
$response = $this->actingAs($user)->get(route('environments.index', [
'organisation' => $organisation->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('environments/Index', false)
->has('applications.0.environments', 1));
});
it('shows the server provider create page', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$response = $this->actingAs($user)->get(route('providers.create', [
'organisation' => $organisation->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('providers/Create', false)
->has('providerTypes'));
});

View File

@@ -62,5 +62,39 @@ it('falls back to the last step when everything is complete', function () {
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->where('nextStep.key', 'application'));
->where('nextStep.key', 'deploy-key'));
});
it('falls back to deploy key when every setup step 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,
'deploy_key_installed_at' => now(),
]);
$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', 'deploy-key'));
});

View File

@@ -0,0 +1,154 @@
<?php
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Operation;
use App\Models\Organisation;
use App\Models\Service;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('lists organisation operations with filters and target context', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
Operation::factory()->create([
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
'target_type' => $environment->getMorphClass(),
'target_id' => $environment->id,
'status' => OperationStatus::IN_PROGRESS,
]);
$response = $this->actingAs($user)->get(route('operations.index', [
'organisation' => $organisation->id,
'kind' => OperationKind::ENVIRONMENT_DEPLOY->value,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('operations/Index', false)
->where('operations.data.0.kind', OperationKind::ENVIRONMENT_DEPLOY->value)
->where('filters.kind', OperationKind::ENVIRONMENT_DEPLOY->value));
});
it('shows an operation with steps and children', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$service = Service::factory()->for($organisation)->create();
$operation = Operation::factory()->create([
'target_type' => $service->getMorphClass(),
'target_id' => $service->id,
]);
$operation->steps()->create([
'name' => 'Deploy container',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'docker ps',
'logs' => 'ok',
]);
Operation::factory()->create([
'parent_id' => $operation->id,
'target_type' => $service->getMorphClass(),
'target_id' => $service->id,
]);
$response = $this->actingAs($user)->get(route('operations.show', [
'organisation' => $organisation->id,
'operation' => $operation->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('operations/Show', false)
->where('operation.hash', $operation->hash)
->has('operation.steps', 1)
->has('operation.children', 1));
});
it('does not show operations from another organisation', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$otherOrganisation = Organisation::factory()->create();
$service = Service::factory()->for($otherOrganisation)->create();
$operation = Operation::factory()->create([
'target_type' => $service->getMorphClass(),
'target_id' => $service->id,
]);
$response = $this->actingAs($user)->get(route('operations.show', [
'organisation' => $organisation->id,
'operation' => $operation->id,
]));
$response->assertNotFound();
});
it('retries failed operations owned by the organisation', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$service = Service::factory()->for($organisation)->create();
$operation = Operation::factory()->create([
'target_type' => $service->getMorphClass(),
'target_id' => $service->id,
'status' => OperationStatus::FAILED,
'started_at' => now(),
'finished_at' => now(),
]);
$response = $this->actingAs($user)->post(route('operations.retry', [
'organisation' => $organisation->id,
'operation' => $operation->id,
]));
$response->assertRedirect(route('operations.show', [
'organisation' => $organisation->id,
'operation' => $operation->id,
]));
expect($operation->refresh()->status)->toBe(OperationStatus::PENDING)
->and($operation->started_at)->toBeNull()
->and($operation->finished_at)->toBeNull();
});
it('cancels operations and downloads logs', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$service = Service::factory()->for($organisation)->create();
$operation = Operation::factory()->create([
'target_type' => $service->getMorphClass(),
'target_id' => $service->id,
'status' => OperationStatus::IN_PROGRESS,
]);
$operation->steps()->create([
'name' => 'Deploy container',
'order' => 1,
'status' => OperationStatus::IN_PROGRESS,
'script' => 'docker ps',
'logs' => 'hello',
'error_logs' => 'error',
]);
$this->actingAs($user)->post(route('operations.cancel', [
'organisation' => $organisation->id,
'operation' => $operation->id,
]))->assertRedirect(route('operations.show', [
'organisation' => $organisation->id,
'operation' => $operation->id,
]));
expect($operation->refresh()->status)->toBe(OperationStatus::CANCELLED)
->and($operation->finished_at)->not->toBeNull();
$response = $this->actingAs($user)->get(route('operations.logs', [
'organisation' => $organisation->id,
'operation' => $operation->id,
]));
$response->assertOk();
$response->assertHeader('content-type', 'text/plain; charset=UTF-8');
});

View File

@@ -0,0 +1,116 @@
<?php
use App\Enums\OrganisationRole;
use App\Models\Organisation;
use App\Models\OrganisationInvitation;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('lists organisation members', function () {
$owner = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $owner->id]);
$member = User::factory()->create();
$organisation->members()->attach($member, ['role' => OrganisationRole::MEMBER]);
$this->actingAs($owner)
->get(route('organisation-members.index', $organisation))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('organisation-members/Index', false)
->has('organisation.members', 1)
->has('organisation.invitations', 0));
});
it('adds updates and removes organisation members', function () {
$owner = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $owner->id]);
$member = User::factory()->create(['email' => 'member@example.com']);
$this->actingAs($owner)
->post(route('organisation-members.store', $organisation), [
'email' => 'member@example.com',
'role' => OrganisationRole::MEMBER->value,
])
->assertRedirect(route('organisation-members.index', $organisation));
expect($organisation->members()->whereKey($member->id)->exists())->toBeTrue();
$this->actingAs($owner)
->put(route('organisation-members.update', [
'organisation' => $organisation->id,
'member' => $member->id,
]), [
'role' => OrganisationRole::ADMIN->value,
])
->assertRedirect(route('organisation-members.index', $organisation));
expect($organisation->members()->whereKey($member->id)->first()->membership->role)
->toBe(OrganisationRole::ADMIN);
$this->actingAs($owner)
->delete(route('organisation-members.destroy', [
'organisation' => $organisation->id,
'member' => $member->id,
]))
->assertRedirect(route('organisation-members.index', $organisation));
expect($organisation->members()->whereKey($member->id)->exists())->toBeFalse();
});
it('creates updates and cancels pending organisation invitations', function () {
$owner = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $owner->id]);
$this->actingAs($owner)
->post(route('organisation-members.store', $organisation), [
'email' => 'pending@example.com',
'role' => OrganisationRole::MEMBER->value,
])
->assertRedirect(route('organisation-members.index', $organisation));
$invitation = $organisation->invitations()->where('email', 'pending@example.com')->firstOrFail();
expect($invitation->role)->toBe(OrganisationRole::MEMBER)
->and($invitation->token)->not->toBeEmpty()
->and($invitation->invited_by_user_id)->toBe($owner->id);
$this->actingAs($owner)
->get(route('organisation-members.index', $organisation))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('organisation-members/Index', false)
->where('organisation.invitations.0.email', 'pending@example.com'));
$this->actingAs($owner)
->put(route('organisation-invitations.update', [
'organisation' => $organisation->id,
'invitation' => $invitation->id,
]), [
'role' => OrganisationRole::ADMIN->value,
])
->assertRedirect(route('organisation-members.index', $organisation));
expect($invitation->refresh()->role)->toBe(OrganisationRole::ADMIN);
$this->actingAs($owner)
->delete(route('organisation-invitations.destroy', [
'organisation' => $organisation->id,
'invitation' => $invitation->id,
]))
->assertRedirect(route('organisation-members.index', $organisation));
expect(OrganisationInvitation::query()->whereKey($invitation->id)->exists())->toBeFalse();
});
it('does not remove the organisation owner', function () {
$owner = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $owner->id]);
$organisation->members()->attach($owner, ['role' => OrganisationRole::ADMIN]);
$this->actingAs($owner)
->delete(route('organisation-members.destroy', [
'organisation' => $organisation->id,
'member' => $owner->id,
]))
->assertUnprocessable();
});

View File

@@ -1,6 +1,9 @@
<?php
use App\Enums\RegistryType;
use App\Models\Application;
use App\Models\BuildArtifact;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
@@ -19,6 +22,25 @@ it('shows the create registry page', function () {
->where('registryTypes.0', RegistryType::GENERIC->value));
});
it('lists registries for an organisation', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$organisation->registries()->create([
'name' => 'GHCR',
'type' => RegistryType::GHCR,
'url' => 'ghcr.io/example',
'credentials' => ['username' => 'keystone', 'password' => 'secret'],
]);
$this->actingAs($user)
->get(route('registries.index', $organisation))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('registries/Index', false)
->has('registries', 1)
->where('registries.0.name', 'GHCR'));
});
it('stores a registry for multi-server build artifacts', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
@@ -47,3 +69,33 @@ it('stores a registry for multi-server build artifacts', function () {
'password' => 'secret',
]);
});
it('shows registry usage from published build artifacts', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$registry = $organisation->registries()->create([
'name' => 'GHCR',
'type' => RegistryType::GHCR,
'url' => 'ghcr.io/example',
'credentials' => ['username' => 'keystone', 'password' => 'secret'],
]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
BuildArtifact::query()->create([
'environment_id' => $environment->id,
'commit_sha' => 'abc123',
'image_tag' => 'app:abc123',
'registry_ref' => 'ghcr.io/example/app:abc123',
]);
$this->actingAs($user)
->get(route('registries.show', [$organisation, $registry]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('registries/Show', false)
->where('registry.id', $registry->id)
->where('artifactCount', 1)
->where('environmentCount', 1)
->has('artifacts.data', 1));
});

View File

@@ -0,0 +1,254 @@
<?php
use App\Enums\BuildArtifactStatus;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Application;
use App\Models\BuildArtifact;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Models\ServiceSlice;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
function serverSetup(): array
{
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'provider_id' => $provider->id,
'external_id' => 'network-1',
'network_zone' => 'global',
'name' => 'keystone-global',
'ip_range' => '10.0.0.0/16',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider((string) $provider->id)
->forNetwork((string) $network->id)
->create();
$service = Service::factory()->for($organisation)->for($server)->create();
return compact('user', 'organisation', 'server', 'service');
}
it('shows replica detail and queues lifecycle operations', function () {
$setup = serverSetup();
$replica = ServiceReplica::factory()
->for($setup['service'])
->for($setup['server'])
->create();
$this->actingAs($setup['user'])
->get(route('service-replicas.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
'replica' => $replica->id,
]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('service-replicas/Show', false)
->where('replica.id', $replica->id));
$this->actingAs($setup['user'])
->post(route('service-replicas.start', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
'replica' => $replica->id,
]))
->assertRedirect(route('service-replicas.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
'replica' => $replica->id,
]));
$this->actingAs($setup['user'])
->post(route('service-replicas.stop', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
'replica' => $replica->id,
]))
->assertRedirect(route('service-replicas.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
'replica' => $replica->id,
]));
$this->actingAs($setup['user'])
->post(route('service-replicas.restart', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
'replica' => $replica->id,
]))
->assertRedirect(route('service-replicas.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
'replica' => $replica->id,
]));
expect($replica->operations()->where('kind', OperationKind::REPLICA_DEPLOY)->count())->toBe(3)
->and($replica->operations()->first()->status)->toBe(OperationStatus::PENDING)
->and($replica->operations()->whereHas('steps', fn ($query) => $query->where('name', 'Start replica')->where('script', "docker start {$replica->container_name}"))->exists())->toBeTrue()
->and($replica->operations()->whereHas('steps', fn ($query) => $query->where('name', 'Stop replica')->where('script', "docker stop {$replica->container_name}"))->exists())->toBeTrue()
->and($replica->operations()->whereHas('steps', fn ($query) => $query->where('name', 'Restart replica')->where('script', "docker restart {$replica->container_name}"))->exists())->toBeTrue();
});
it('creates and shows service slices with independent operations', function () {
$setup = serverSetup();
$this->actingAs($setup['user'])
->get(route('service-slices.create', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('service-slices/Create', false));
$this->actingAs($setup['user'])
->post(route('service-slices.store', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
]), [
'name' => 'readonly',
'type' => 'database_user',
'status' => 'pending',
'config' => '{"access":"readonly"}',
])
->assertRedirect();
$slice = $setup['service']->slices()->where('name', 'readonly')->firstOrFail();
$slice->operations()->create([
'kind' => OperationKind::SLICE_PROVISION,
'status' => OperationStatus::PENDING,
]);
$this->actingAs($setup['user'])
->get(route('service-slices.index', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('service-slices/Index', false)
->has('slices', 1)
->where('slices.0.id', $slice->id)
->has('slices.0.operations', 1));
$this->actingAs($setup['user'])
->get(route('service-slices.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
'service' => $setup['service']->id,
'slice' => $slice->id,
]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('service-slices/Show', false)
->where('slice.id', $slice->id)
->has('slice.operations', 1));
});
it('updates and detaches environment attachments', function () {
$setup = serverSetup();
$application = Application::factory()->for($setup['organisation'])->create();
$environment = Environment::factory()->for($application)->create();
$slice = ServiceSlice::factory()->for($setup['service'])->for($environment)->create();
$attachment = $environment->attachments()->create([
'service_id' => $setup['service']->id,
'service_slice_id' => $slice->id,
'role' => EnvironmentAttachmentRole::DATABASE,
'env_prefix' => null,
'is_primary' => true,
]);
$this->actingAs($setup['user'])
->get(route('environment-attachments.edit', [
'organisation' => $setup['organisation']->id,
'application' => $application->id,
'environment' => $environment->id,
'attachment' => $attachment->id,
]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('environment-attachments/Edit', false));
$this->actingAs($setup['user'])
->put(route('environment-attachments.update', [
'organisation' => $setup['organisation']->id,
'application' => $application->id,
'environment' => $environment->id,
'attachment' => $attachment->id,
]), [
'role' => EnvironmentAttachmentRole::CACHE->value,
'env_prefix' => 'CACHE',
'is_primary' => false,
])
->assertRedirect(route('environments.show', [$setup['organisation'], $application, $environment]));
expect($attachment->refresh()->role)->toBe(EnvironmentAttachmentRole::CACHE)
->and($attachment->env_prefix)->toBe('CACHE')
->and($attachment->is_primary)->toBeFalse();
$this->actingAs($setup['user'])
->delete(route('environment-attachments.destroy', [
'organisation' => $setup['organisation']->id,
'application' => $application->id,
'environment' => $environment->id,
'attachment' => $attachment->id,
]))
->assertRedirect(route('environments.show', [$setup['organisation'], $application, $environment]));
expect($environment->attachments()->exists())->toBeFalse();
});
it('lists and shows build artifacts', function () {
$setup = serverSetup();
$application = Application::factory()->for($setup['organisation'])->create();
$environment = Environment::factory()->for($application)->create();
$artifact = BuildArtifact::query()->create([
'environment_id' => $environment->id,
'commit_sha' => 'abc123',
'image_tag' => 'app:abc123',
'image_digest' => 'sha256:test',
'registry_ref' => 'registry.example.com/app:abc123',
'built_by_service_id' => $setup['service']->id,
'status' => BuildArtifactStatus::AVAILABLE,
'metadata' => ['build_strategy' => 'target_server'],
]);
$this->actingAs($setup['user'])
->get(route('build-artifacts.index', [$setup['organisation'], $application, $environment]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('build-artifacts/Index', false)
->has('artifacts.data', 1));
$this->actingAs($setup['user'])
->get(route('build-artifacts.show', [
'organisation' => $setup['organisation']->id,
'application' => $application->id,
'environment' => $environment->id,
'artifact' => $artifact->id,
]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('build-artifacts/Show', false)
->where('artifact.id', $artifact->id));
});

View File

@@ -1,221 +1,167 @@
<?php
use App\Data\ServerProviders\CreatedServer;
use App\Enums\ProviderType;
use App\Actions\FirewallRules\InstallFirewallRule;
use App\Actions\FirewallRules\UninstallFirewallRule;
use App\Enums\FirewallRuleType;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServerStatus;
use App\Models\FirewallRule;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\User;
use App\Services\ServerProviders\HetznerService;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Mockery\MockInterface;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\mock;
beforeEach(function () {
// If you have database migrations or any setup, include it here
// For example, using Laravel's RefreshDatabase trait
// use Illuminate\Foundation\Testing\RefreshDatabase;
/** @var User $user */
$this->user = User::factory()->create();
actingAs($this->user);
});
test('index route displays servers for an organisation', function () {
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation->id)->create();
$network = $organisation->networks()->create([
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => fake()->ipv4().'/24',
]);
Server::factory()->count(2)->create([
'provider_id' => $provider->id,
'organisation_id' => $organisation->id,
'network_id' => $network->id,
]);
$response = $this->get(route('servers.index', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Index', false));
});
test('create route returns inertia view', function () {
$organisation = Organisation::factory()->create();
$response = $this->get(route('servers.create', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Create', false));
});
test('store route fails with invalid provider', function () {
$organisation = Organisation::factory()->create();
$response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [
'provider' => 'invalid_provider',
'server_type' => 'cx11',
'location' => 'hel1',
'image' => 'ubuntu-20.04',
]);
$response->assertSessionHasErrors(['provider' => 'The selected provider is invalid.']);
$response->assertStatus(302); // redirect back
});
test('store route creates a server with valid data', function () {
$organisation = Organisation::factory()->create();
// Create a real provider first, then partially mock it
$provider = Provider::factory()->create([
'name' => 'hetzner',
'type' => ProviderType::HETZNER,
'token' => Str::uuid(),
'organisation_id' => $organisation->id,
]);
$network = $organisation->networks()->create([
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => fake()->ipv4().'/24',
]);
$this->partialMock(HetznerService::class, function (MockInterface $mock) use ($network) {
$mock->shouldReceive('forProvider')
->andReturnSelf();
$mock->shouldReceive('createServer')
->once()
->andReturn(new CreatedServer(
name: 'test-server-from-mock',
rootPassword: 'password123',
id: 'srv-12345',
status: 'running',
ipv4: '192.0.2.100',
ipv6: '2001:db8::100',
networkId: $network->external_id,
privateIp: '10.0.0.1',
));
$mock->shouldReceive('createNetwork')->never();
});
$response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [
'provider' => $provider->id,
'server_type' => 'cx11',
'location' => 'hel1',
'image' => 'ubuntu-20.04',
]);
$response->assertRedirectContains('/servers/');
$this->assertDatabaseHas('servers', [
'organisation_id' => $organisation->id,
'provider_id' => $provider->id,
'region' => 'hel1',
'os' => 'ubuntu-20.04',
'network_id' => $network->id,
]);
});
test('show route displays a single server', function () {
$organisation = Organisation::factory()->create();
it('lists private network membership on the servers index', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => fake()->ipv4().'/24',
]);
$server = Server::factory()->create([
'organisation_id' => $organisation->id,
'network_id' => $network->id,
'provider_id' => $provider->id,
'external_id' => 'network-1',
'network_zone' => 'eu-central',
'name' => 'keystone-eu-central',
'ip_range' => '10.42.0.0/16',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider((string) $provider->id)
->forNetwork((string) $network->id)
->create([
'name' => 'app-1',
'private_ip' => '10.42.0.10',
]);
$response = $this->get(route('servers.show', [
'organisation' => $organisation->id,
'server' => $server->id,
]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Show', false));
$this->actingAs($user)
->get(route('servers.index', $organisation))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Index', false)
->where('networks.0.name', 'keystone-eu-central')
->where('networks.0.servers.0.id', $server->id)
->where('networks.0.servers.0.private_ip', '10.42.0.10'));
});
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', [
it('queues a server heal operation for failed provisioning', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'provider_id' => $provider->id,
'external_id' => 'net-99',
'external_id' => 'network-1',
'network_zone' => 'global',
'name' => 'keystone-global',
'ip_range' => '10.0.0.0/16',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider((string) $provider->id)
->forNetwork((string) $network->id)
->create([
'status' => ServerStatus::PROVISIONING_FAILED,
'user' => 'keystone',
]);
$this->actingAs($user)
->get(route('servers.show', [$organisation, $server]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Show', false)
->where('server.status', ServerStatus::PROVISIONING_FAILED->value));
$this->actingAs($user)
->post(route('servers.heal', [$organisation, $server]))
->assertRedirect(route('servers.show', [$organisation, $server]));
$operation = $server->operations()->with('steps')->firstOrFail();
expect($operation->kind)->toBe(OperationKind::SERVER_PROVISION)
->and($operation->status)->toBe(OperationStatus::PENDING)
->and($operation->steps)->toHaveCount(3)
->and($operation->steps->pluck('name')->all())->toBe([
'Check server shell',
'Check Docker',
'Check Keystone directories',
]);
});
it('creates and removes server firewall rules from the server page', function () {
mock(InstallFirewallRule::class)->shouldReceive('execute')->andReturnNull();
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'provider_id' => $provider->id,
'external_id' => 'network-1',
'network_zone' => 'global',
'name' => 'keystone-global',
'ip_range' => '10.0.0.0/16',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider((string) $provider->id)
->forNetwork((string) $network->id)
->create(['user' => 'keystone']);
$this->actingAs($user)
->post(route('servers.firewall-rules.store', [$organisation, $server]), [
'type' => FirewallRuleType::ALLOW->value,
'ports' => '443/tcp',
'from' => '10.0.0.0/16',
])
->assertRedirect(route('servers.show', [$organisation, $server]));
$rule = $server->firewallRules()->firstOrFail();
expect($rule->type)->toBe(FirewallRuleType::ALLOW)
->and($rule->ports)->toBe('443/tcp')
->and($rule->from)->toBe('10.0.0.0/16');
$this->actingAs($user)
->get(route('servers.show', [$organisation, $server]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Show', false)
->has('server.firewall_rules', 1)
->where('server.firewall_rules.0.ports', '443/tcp'));
mock(UninstallFirewallRule::class)->shouldReceive('execute')->once();
$this->actingAs($user)
->delete(route('servers.firewall-rules.destroy', [$organisation, $server, $rule]))
->assertRedirect(route('servers.show', [$organisation, $server]));
expect(FirewallRule::query()->whereKey($rule->id)->exists())->toBeFalse();
});
it('validates server firewall rules', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'provider_id' => $provider->id,
'external_id' => 'network-1',
'network_zone' => 'global',
'name' => 'keystone-global',
'ip_range' => '10.0.0.0/16',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider((string) $provider->id)
->forNetwork((string) $network->id)
->create(['user' => 'keystone']);
$this->actingAs($user)
->post(route('servers.firewall-rules.store', [$organisation, $server]), [
'type' => 'open',
'ports' => '443 tcp',
'from' => 'not a source',
])
->assertInvalid(['type', 'ports', 'from']);
expect($server->firewallRules()->exists())->toBeFalse();
});

View File

@@ -370,6 +370,70 @@ test('show service page renders inertia view', function () {
->component('services/Show', false));
});
test('show environment service page renders serverless service details', function () {
$setup = setupTestEnvironment();
actingAs($setup['user']);
$application = \App\Models\Application::factory()->create([
'organisation_id' => $setup['organisation']->id,
]);
$environment = \App\Models\Environment::factory()->create([
'application_id' => $application->id,
]);
$service = Service::factory()->create([
'environment_id' => $environment->id,
'organisation_id' => $setup['organisation']->id,
'server_id' => null,
'name' => 'scheduler',
]);
$response = $this->get(route('environment-services.show', [
'organisation' => $setup['organisation']->id,
'application' => $application->id,
'environment' => $environment->id,
'service' => $service->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('services/Show', false)
->where('server', null)
->where('service.id', $service->id)
->where('service.name', 'scheduler')
->where('environment.id', $environment->id)
->where('application.id', $application->id));
});
test('environment service page scopes service to environment', function () {
$setup = setupTestEnvironment();
actingAs($setup['user']);
$application = \App\Models\Application::factory()->create([
'organisation_id' => $setup['organisation']->id,
]);
$environment = \App\Models\Environment::factory()->create([
'application_id' => $application->id,
]);
$otherEnvironment = \App\Models\Environment::factory()->create([
'application_id' => $application->id,
'name' => 'staging',
]);
$service = Service::factory()->create([
'environment_id' => $otherEnvironment->id,
'organisation_id' => $setup['organisation']->id,
'server_id' => null,
]);
$response = $this->get(route('environment-services.show', [
'organisation' => $setup['organisation']->id,
'application' => $application->id,
'environment' => $environment->id,
'service' => $service->id,
]));
$response->assertNotFound();
});
test('edit service page renders inertia view', function () {
$setup = setupTestEnvironment();
actingAs($setup['user']);
@@ -410,6 +474,8 @@ test('update service persists changes and redirects', function () {
'desired_replicas' => 4,
'default_cpu_limit' => 2,
'default_memory_limit_mb' => 1024,
'backup_enabled' => true,
'backup_command' => 'pg_dump app > /home/keystone/backups/pre-update.sql',
]);
$response->assertRedirect(route('services.show', [
@@ -422,4 +488,8 @@ test('update service persists changes and redirects', function () {
$service->refresh();
expect($service->name)->toBe('web-renamed');
expect($service->desired_replicas)->toBe(4);
expect($service->config)->toMatchArray([
'backup_enabled' => true,
'backup_command' => 'pg_dump app > /home/keystone/backups/pre-update.sql',
]);
});

View File

@@ -9,6 +9,7 @@ use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Models\User;
use App\Services\Operations\RemoteCommandRunner;
use Inertia\Testing\AssertableInertia;
it('shows the stateful service update page with backup capability', function () {
@@ -43,6 +44,7 @@ it('stores an explicit stateful update operation', function () {
]), [
'image_digest' => 'sha256:newdigest',
'backup_requested' => true,
'confirmation' => 'postgres',
]);
$response->assertRedirect(route('servers.show', [
@@ -58,6 +60,26 @@ it('stores an explicit stateful update operation', function () {
->and($service->update_status)->toBe('update_pending');
});
it('resolves the latest service image digest from the update page', function () {
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
{
public function run(Server $server, string $script): string
{
return "image_digest=postgres:18@sha256:resolveddigest\n";
}
});
[$user, $organisation, $server, $service] = serviceUpdateFixture([
'backup_enabled' => true,
]);
$this->actingAs($user)
->post(route('service-updates.resolve', [$organisation, $server, $service]))
->assertRedirect(route('service-updates.create', [$organisation, $server, $service]));
expect($service->refresh()->available_image_digest)->toBe('sha256:resolveddigest');
});
/**
* @return array{0: User, 1: Organisation, 2: Server, 3: Service}
*/