wowowowowo
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
199
tests/Feature/CrudUiTest.php
Normal file
199
tests/Feature/CrudUiTest.php
Normal 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');
|
||||
});
|
||||
7
tests/Feature/DatabaseSeederTest.php
Normal file
7
tests/Feature/DatabaseSeederTest.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
test('example', function () {
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
53
tests/Feature/NavigationUiTest.php
Normal file
53
tests/Feature/NavigationUiTest.php
Normal 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'));
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
154
tests/Feature/OperationsUiTest.php
Normal file
154
tests/Feature/OperationsUiTest.php
Normal 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');
|
||||
});
|
||||
116
tests/Feature/OrganisationMemberControllerTest.php
Normal file
116
tests/Feature/OrganisationMemberControllerTest.php
Normal 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();
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
254
tests/Feature/ResourceDetailUiTest.php
Normal file
254
tests/Feature/ResourceDetailUiTest.php
Normal 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));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user