Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai committed 2026-07-18 15:57:26 +02:00
commit ae32ab5d7b
139 files changed
+8677 -545

No files matched your search

@@ -0,0 +1,47 @@
<?php
use App\Livewire\Admin\Index as AdminIndex;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('admin search only shows users with paid subscriptions as active', function () {
config()->set('cache.default', 'array');
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::unguarded(
fn () => InstanceSettings::query()->firstOrCreate(['id' => 0])
);
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$rootUser = User::find(0) ?? User::factory()->create(['id' => 0]);
$rootTeam->members()->syncWithoutDetaching([
$rootUser->id => ['role' => 'admin'],
]);
$inactiveUser = User::factory()->create(['email' => 'inactive@example.com']);
$inactiveTeam = Team::factory()->create();
$inactiveTeam->members()->attach($inactiveUser->id, ['role' => 'owner']);
Subscription::create([
'team_id' => $inactiveTeam->id,
'stripe_subscription_id' => 'sub_stale',
'stripe_invoice_paid' => false,
]);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->set([
'foundUsers' => collect(),
'search' => 'inactive@example.com',
])
->call('submitSearch')
->assertSee('No')
->assertDontSee('Yes');
});
@@ -0,0 +1,257 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('ssh-keys');
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = $this->user->createToken('build-secrets-api-test', ['*'])->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function buildSecretsApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function buildSecretsGithubPrivateKey(): string
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
return $privateKey;
}
describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () {
test('updates the application setting', function () {
expect($this->application->settings->use_build_secrets)->toBeFalse();
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'use_build_secrets' => true,
])
->assertOk();
expect($this->application->fresh()->settings->use_build_secrets)->toBeTrue();
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'use_build_secrets' => false,
])
->assertOk();
expect($this->application->fresh()->settings->use_build_secrets)->toBeFalse();
});
test('rejects non boolean values', function () {
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'use_build_secrets' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('use_build_secrets');
});
test('does not change the setting when omitted', function () {
$this->application->settings->update(['use_build_secrets' => true]);
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'name' => 'updated-name',
])
->assertOk();
expect($this->application->fresh()->settings->use_build_secrets)->toBeTrue();
});
});
describe('POST /api/v1/applications/public use_build_secrets', function () {
test('creates an application with the requested build secrets setting', function (bool $useBuildSecrets) {
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/build-secrets-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'use_build_secrets' => $useBuildSecrets,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBe($useBuildSecrets);
})->with([
'enabled' => true,
'disabled' => false,
]);
test('rejects non boolean values', function () {
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/build-secrets-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'use_build_secrets' => 'not-a-boolean',
'autogenerate_domain' => false,
])
->assertUnprocessable()
->assertJsonValidationErrors('use_build_secrets');
});
});
describe('other application creation endpoints use_build_secrets', function () {
test('creates a Dockerfile application with build secrets enabled', function () {
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/dockerfile', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"),
'use_build_secrets' => true,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
});
test('creates a Docker image application with build secrets enabled', function () {
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/dockerimage', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'alpine',
'ports_exposes' => '80',
'use_build_secrets' => true,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
});
test('creates a private deploy key application with build secrets enabled', function () {
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/private-deploy-key', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'private_key_uuid' => $privateKey->uuid,
'git_repository' => 'git@gitlab.com:coolify/build-secrets-test.git',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'use_build_secrets' => true,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
});
test('creates a private GitHub App application with build secrets enabled', function () {
$privateKey = PrivateKey::create([
'name' => 'GitHub App Key',
'private_key' => buildSecretsGithubPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Build Secrets GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'build-secrets-client-id',
'client_secret' => 'build-secrets-client-secret',
'webhook_secret' => 'build-secrets-webhook-secret',
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'github-installation-token',
], 201),
'https://api.github.com/repos/coolify/build-secrets-test' => Http::response([
'id' => 123456,
]),
]);
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/private-github-app', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'github_app_uuid' => $githubApp->uuid,
'git_repository' => 'coolify/build-secrets-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'use_build_secrets' => true,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
});
});
@@ -0,0 +1,183 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = $this->user->createToken('application-settings-api-test', ['*'])->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function applicationSettingsApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function recommendedApplicationSettingsPayload(): array
{
return [
'is_git_submodules_enabled' => false,
'is_git_lfs_enabled' => false,
'is_git_shallow_clone_enabled' => false,
'disable_build_cache' => true,
'inject_build_args_to_dockerfile' => false,
'include_source_commit_in_build' => true,
'is_env_sorting_enabled' => true,
'is_pr_deployments_public_enabled' => true,
'stop_grace_period' => 45,
'docker_images_to_keep' => 7,
'is_gzip_enabled' => false,
'is_stripprefix_enabled' => false,
'is_raw_compose_deployment_enabled' => true,
];
}
test('GET /api/v1/applications/{uuid} includes settings without internal metadata', function () {
$this->application->settings->update(recommendedApplicationSettingsPayload());
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertOk()
->assertJsonPath('settings.disable_build_cache', true)
->assertJsonPath('settings.stop_grace_period', 45)
->assertJsonMissingPath('settings.id')
->assertJsonMissingPath('settings.application_id')
->assertJsonMissingPath('settings.created_at')
->assertJsonMissingPath('settings.updated_at');
});
test('PATCH /api/v1/applications/{uuid} updates application settings', function () {
$this->application->update(['build_pack' => 'dockercompose']);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", recommendedApplicationSettingsPayload())
->assertOk();
$settings = $this->application->fresh()->settings;
foreach (recommendedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
});
test('application creation accepts application settings', function () {
Queue::fake();
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', array_merge([
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/application-settings-test',
'git_branch' => 'main',
'build_pack' => 'dockercompose',
'autogenerate_domain' => false,
], recommendedApplicationSettingsPayload()))
->assertCreated();
$settings = Application::where('uuid', $response->json('uuid'))->firstOrFail()->settings;
foreach (recommendedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
});
test('proxy settings regenerate managed labels', function () {
$this->application->settings->update([
'is_container_label_readonly_enabled' => true,
'is_gzip_enabled' => true,
'is_stripprefix_enabled' => true,
]);
$this->application->update(['custom_labels' => base64_encode('sentinel-label=true')]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_gzip_enabled' => false,
'is_stripprefix_enabled' => false,
])
->assertOk();
expect(base64_decode($this->application->fresh()->custom_labels))->not->toContain('sentinel-label=true');
});
test('rejects invalid boolean application settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'disable_build_cache' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('disable_build_cache');
});
test('validates stop grace period bounds', function (int $stopGracePeriod) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'stop_grace_period' => $stopGracePeriod,
])
->assertUnprocessable()
->assertJsonValidationErrors('stop_grace_period');
})->with([
'below minimum' => 0,
'above maximum' => 3601,
]);
test('validates Docker image retention bounds', function (int $dockerImagesToKeep) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'docker_images_to_keep' => $dockerImagesToKeep,
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_images_to_keep');
})->with([
'below minimum' => -1,
'above maximum' => 101,
]);
test('stop grace period can be reset to null', function () {
$this->application->settings->update(['stop_grace_period' => 45]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'stop_grace_period' => null,
])
->assertOk();
expect($this->application->fresh()->settings->stop_grace_period)->toBeNull();
});
test('raw compose deployment can only be enabled for Docker Compose applications', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_raw_compose_deployment_enabled' => true,
])
->assertUnprocessable()
->assertJsonValidationErrors('is_raw_compose_deployment_enabled');
});
@@ -5,8 +5,10 @@ use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -15,7 +17,7 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -77,6 +79,56 @@ function backupHeaders(): array
}
describe('POST /api/v1/databases/{uuid}/backups', function () {
test('rejects backup configurations for unsupported database types', function () {
$database = StandaloneRedis::create([
'uuid' => (string) Str::uuid(),
'name' => 'Redis DB',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertUnprocessable()
->assertJson([
'message' => 'Scheduled backups are not supported for this database type.',
]);
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
test('defaults clickhouse backups to its configured database', function () {
$database = StandaloneClickhouse::create([
'uuid' => (string) Str::uuid(),
'name' => 'ClickHouse DB',
'clickhouse_admin_user' => 'default',
'clickhouse_admin_password' => 'password',
'clickhouse_db' => 'analytics',
'image' => 'clickhouse/clickhouse-server:25.11',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertCreated();
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->firstOrFail();
expect($backup->databases_to_backup)->toBe('analytics')
->and($backup->database_type)->toBe(StandaloneClickhouse::class);
});
test('creates backup configuration with valid frequency', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
+20
View File
@@ -102,6 +102,26 @@ describe('GET /api/v1/servers/{server_uuid}/destinations', function () {
});
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
test('creates a standalone destination', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'name' => 'API Standalone',
'network' => 'api-standalone-network',
]);
$response->assertCreated()
->assertJson([
'name' => 'API Standalone',
'network' => 'api-standalone-network',
'type' => 'standalone',
'server_uuid' => $this->server->uuid,
]);
expect(StandaloneDocker::where('server_id', $this->server->id)
->where('network', 'api-standalone-network')
->exists())->toBeTrue();
});
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
@@ -11,6 +11,7 @@ use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Visus\Cuid2\Cuid2;
@@ -117,6 +118,48 @@ test('changeSource rejects an arbitrary class as source_type', function () {
expect($this->applicationA->source_type)->not->toBe(Server::class);
});
test('changeSource dispatches configuration changed for an owned source', function () {
Http::fake([
'https://api.github.com/repos/*' => Http::response(['id' => 123]),
]);
$source = GithubApp::create([
'name' => 'own-github-app',
'team_id' => $this->teamA->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => true,
]);
$this->applicationA->update(['git_repository' => 'coollabsio/coolify']);
Livewire::test(Source::class, ['application' => $this->applicationA->fresh()])
->call('changeSource', $source->id, GithubApp::class)
->assertDispatched('configurationChanged');
});
test('changeSource dispatches configuration changed when repository metadata lookup fails after persistence', function () {
Http::fake([
'https://api.github.com/repos/*' => Http::response(
['message' => 'Unavailable'],
503,
['X-RateLimit-Reset' => now()->addMinute()->timestamp],
),
]);
$source = GithubApp::create([
'name' => 'own-unavailable-github-app',
'team_id' => $this->teamA->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => true,
]);
$this->applicationA->update(['git_repository' => 'coollabsio/coolify']);
Livewire::test(Source::class, ['application' => $this->applicationA->fresh()])
->call('changeSource', $source->id, GithubApp::class)
->assertDispatched('configurationChanged');
expect($this->applicationA->refresh()->source_id)->toBe($source->id);
});
test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () {
// Without #[Locked], an attacker could POST {"updates": {"privateKeyId": <foreign_id>},
// "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through
@@ -15,7 +15,10 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->team = Team::factory()->create();
@@ -3,21 +3,28 @@
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
// Team A (attacker's team)
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail();
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
@@ -30,7 +37,7 @@ beforeEach(function () {
// Team B (victim's team)
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail();
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
@@ -40,7 +47,7 @@ beforeEach(function () {
test('cloneTo rejects destination belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationB->id)
->call('cloneTo', $this->destinationB->uuid)
->assertHasErrors('destination_id');
// Ensure no cross-tenant application was created
@@ -48,12 +55,16 @@ test('cloneTo rejects destination belonging to another team', function () {
});
test('cloneTo allows destination belonging to own team', function () {
$secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$secondDestination = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'second-destination',
]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $secondDestination->id)
->assertHasNoErrors('destination_id')
->assertRedirect();
->call('cloneTo', $secondDestination->uuid)
->assertHasNoErrors('destination_id');
expect(Application::count())->toBe(2);
});
test('moveTo rejects environment belonging to another team', function () {
@@ -0,0 +1,154 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
});
test('it previews eligible unverified users without deleting them', function () {
$user = User::factory()->unverified()->create([
'email' => 'unverified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users')
->expectsOutput('Found 1 unverified user eligible for deletion.')
->expectsOutput('Dry run only. Use --yes to delete eligible users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it reports dry run progress', function () {
$users = User::factory()->unverified()->count(2)->create();
$exitCode = Artisan::call('cloud:cleanup-unverified-users');
expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('Checking eligible users: 2/2');
$users->each(fn (User $user) => $this->assertModelExists($user));
});
test('it deletes eligible unverified users with the yes option', function () {
$user = User::factory()->unverified()->create([
'email' => 'unverified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 1 unverified user.')
->assertSuccessful();
$this->assertModelMissing($user);
});
test('it reports deletion progress', function () {
User::factory()->unverified()->count(2)->create();
$exitCode = Artisan::call('cloud:cleanup-unverified-users', ['--yes' => true]);
expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('Deleting eligible users: 2/2');
});
test('it keeps verified users', function () {
$user = User::factory()->create([
'email' => 'verified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with a Stripe subscription record', function () {
$user = User::factory()->unverified()->create([
'email' => 'subscribed@example.com',
]);
Subscription::create([
'team_id' => $user->teams()->firstOrFail()->id,
'stripe_invoice_paid' => false,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with defined resources', function () {
$user = User::factory()->unverified()->create([
'email' => 'resource-owner@example.com',
]);
$project = Project::factory()->create([
'team_id' => $user->teams()->firstOrFail()->id,
]);
$environment = Environment::factory()->create([
'project_id' => $project->id,
]);
Application::factory()->create([
'environment_id' => $environment->id,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with servers', function () {
$user = User::factory()->unverified()->create([
'email' => 'server-owner@example.com',
]);
Server::factory()->create([
'team_id' => $user->teams()->firstOrFail()->id,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified root team members', function () {
$user = User::factory()->unverified()->create([
'email' => 'root-member@example.com',
]);
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
]);
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it only runs on Coolify Cloud', function () {
config()->set('constants.coolify.self_hosted', true);
$this->artisan('cloud:cleanup-unverified-users')
->expectsOutput('This command can only be run on Coolify Cloud.')
->assertFailed();
});
@@ -0,0 +1,101 @@
<?php
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('backups');
config()->set('constants.coolify.self_hosted', false);
});
test('it exports subscribed and unsubscribed verified users to separate files without tags', function () {
User::factory()->create([
'id' => 0,
'name' => 'Root User',
'email' => 'root@example.com',
]);
$subscribedUser = User::factory()->create([
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
]);
Subscription::create([
'team_id' => $subscribedUser->teams()->firstOrFail()->id,
'stripe_invoice_paid' => true,
'stripe_subscription_id' => 'sub_active',
]);
User::factory()->create([
'name' => 'Grace',
'email' => 'grace@example.com',
]);
User::factory()->unverified()->create([
'name' => 'Unverified User',
'email' => 'unverified@example.com',
]);
Storage::disk('backups')->put('cloud-users.csv', 'old export');
$this->artisan('cloud:export-users')->assertSuccessful();
Storage::disk('backups')->assertExists([
'cloud-users-subscribed.csv',
'cloud-users-unsubscribed.csv',
]);
Storage::disk('backups')->assertMissing('cloud-users.csv');
$readCsv = function (string $filename): array {
$output = fopen(Storage::disk('backups')->path($filename), 'rb');
$rows = [];
while (($row = fgetcsv($output, null, ',', '"', '')) !== false) {
$rows[] = $row;
}
fclose($output);
return $rows;
};
$header = [
'email',
'first_name',
'last_name',
'lifetime_value_currency',
'lifetime_value_amount',
'utm_campaign',
'utm_source',
'utm_medium',
'utm_content',
'utm_term',
'phone',
];
expect($readCsv('cloud-users-subscribed.csv'))->toBe([
$header,
['ada@example.com', 'Ada', 'Lovelace', '', '', '', '', '', '', '', ''],
])->and($readCsv('cloud-users-unsubscribed.csv'))->toBe([
$header,
['grace@example.com', 'Grace', '', '', '', '', '', '', '', '', ''],
]);
});
test('it only runs on Coolify Cloud', function () {
config()->set('constants.coolify.self_hosted', true);
$this->artisan('cloud:export-users')
->expectsOutput('This command can only be run on Coolify Cloud.')
->assertFailed();
Storage::disk('backups')->assertMissing([
'cloud-users.csv',
'cloud-users-subscribed.csv',
'cloud-users-unsubscribed.csv',
]);
});
@@ -6,8 +6,10 @@ use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -136,3 +138,49 @@ it('creates a scheduled backup with a valid team-owned S3 storage', function ()
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('creates a clickhouse backup for its configured database', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneClickhouse::create([
'name' => 'clickhouse-scheduled-backup',
'clickhouse_admin_user' => 'default',
'clickhouse_admin_password' => 'password',
'clickhouse_db' => 'analytics',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', 'daily')
->call('submit')
->assertDispatched('refreshScheduledBackups');
$backup = ScheduledDatabaseBackup::firstOrFail();
expect($backup->database_type)->toBe(StandaloneClickhouse::class)
->and($backup->databases_to_backup)->toBe('analytics');
});
it('rejects scheduled backups for unsupported database types', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneRedis::create([
'name' => 'redis-without-backups',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', 'daily')
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
+104
View File
@@ -3,6 +3,7 @@
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -133,6 +134,109 @@ describe('POST /api/v1/servers/digitalocean', function () {
&& $request['user_data'] === '#cloud-config');
});
test('tracks the droplet with a placeholder IP when waiting for its IP fails', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
'links' => ['pages' => []],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => [
'id' => 987,
'name' => 'waiting-for-ip',
'status' => 'new',
'networks' => ['v4' => [], 'v6' => []],
],
], 202),
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
'message' => 'temporary provider failure',
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/digitalocean', [
'cloud_provider_token_id' => $this->digitalOceanToken->uuid,
'region' => 'nyc1',
'size' => 's-1vcpu-1gb',
'image' => 'ubuntu-24-04-x64',
'name' => 'waiting-for-ip',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertCreated();
$response->assertJsonFragment([
'digitalocean_droplet_id' => 987,
'ip' => Server::PLACEHOLDER_IP,
]);
$this->assertDatabaseHas('servers', [
'name' => 'waiting-for-ip',
'ip' => Server::PLACEHOLDER_IP,
'team_id' => $this->team->id,
'digitalocean_droplet_id' => 987,
'digitalocean_droplet_status' => 'new',
]);
});
test('deletes the droplet when local server persistence fails', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
'links' => ['pages' => []],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => [
'id' => 987,
'name' => 'persistence-fails',
'status' => 'active',
'networks' => [
'v4' => [
['ip_address' => '203.0.113.10', 'type' => 'public'],
],
],
],
], 202),
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
]);
$eventDispatcher = Server::getEventDispatcher();
Server::setEventDispatcher(clone $eventDispatcher);
Server::created(function (): void {
throw new RuntimeException('local persistence failed');
});
try {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/digitalocean', [
'cloud_provider_token_id' => $this->digitalOceanToken->uuid,
'region' => 'nyc1',
'size' => 's-1vcpu-1gb',
'image' => 'ubuntu-24-04-x64',
'name' => 'persistence-fails',
'private_key_uuid' => $this->privateKey->uuid,
]);
} finally {
Server::setEventDispatcher($eventDispatcher);
}
$response->assertServerError();
$this->assertDatabaseMissing('servers', [
'digitalocean_droplet_id' => 987,
]);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/987');
});
test('validates required fields', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
@@ -1,10 +1,14 @@
<?php
use App\Livewire\Server\New\ByDigitalOcean;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -25,6 +29,136 @@ beforeEach(function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->digitalOceanToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'digitalocean',
'token' => 'test-digitalocean-token',
'name' => 'Test DigitalOcean Token',
]);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
});
function submitDigitalOceanServer(): void
{
Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => test()->digitalOceanToken->uuid])
->assertSet('current_step', 2)
->set('server_name', 'test-do-server')
->set('selected_region', 'nyc1')
->set('selected_size', 's-1vcpu-1gb')
->set('selected_image', 'ubuntu-24-04-x64')
->set('private_key_id', test()->privateKey->id)
->call('submit')
->assertHasNoErrors();
}
it('persists the server with a placeholder IP when waiting for the droplet IP fails', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => ['id' => 555, 'status' => 'new'],
], 202),
'https://api.digitalocean.com/v2/droplets/555' => Http::response(['message' => 'server error'], 500),
]);
submitDigitalOceanServer();
$this->assertDatabaseHas('servers', [
'name' => 'test-do-server',
'ip' => Server::PLACEHOLDER_IP,
'team_id' => $this->team->id,
'cloud_provider_token_id' => $this->digitalOceanToken->id,
'digitalocean_droplet_id' => '555',
'digitalocean_droplet_status' => 'new',
]);
});
it('updates the placeholder IP once the droplet reports one', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => ['id' => 555, 'status' => 'new'],
], 202),
'https://api.digitalocean.com/v2/droplets/555' => Http::response([
'droplet' => [
'id' => 555,
'status' => 'active',
'networks' => [
'v4' => [
['type' => 'public', 'ip_address' => '203.0.113.40'],
],
],
],
], 200),
]);
submitDigitalOceanServer();
$this->assertDatabaseHas('servers', [
'name' => 'test-do-server',
'ip' => '203.0.113.40',
'digitalocean_droplet_id' => '555',
'digitalocean_droplet_status' => 'active',
]);
});
it('deletes the droplet when local server persistence fails', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => [
'id' => 555,
'status' => 'active',
'networks' => [
'v4' => [
['type' => 'public', 'ip_address' => '203.0.113.40'],
],
],
],
], 202),
'https://api.digitalocean.com/v2/droplets/555' => Http::response(null, 204),
]);
$eventDispatcher = Server::getEventDispatcher();
Server::setEventDispatcher(clone $eventDispatcher);
Server::created(function (): void {
throw new RuntimeException('local persistence failed');
});
try {
Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => $this->digitalOceanToken->uuid])
->set('server_name', 'persistence-fails')
->set('selected_region', 'nyc1')
->set('selected_size', 's-1vcpu-1gb')
->set('selected_image', 'ubuntu-24-04-x64')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertDispatched('error', 'local persistence failed');
} finally {
Server::setEventDispatcher($eventDispatcher);
}
$this->assertDatabaseMissing('servers', [
'digitalocean_droplet_id' => 555,
]);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/555');
});
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
@@ -21,6 +21,20 @@ it('uses distinct keyed branches for the edit value field modes', function () {
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
});
it('keeps the environment variable delete button compact', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)->not->toContain('buttonFullWidth="true"');
});
it('aligns environment variable settings and actions in a responsive row', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('class="flex w-full flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"')
->toContain('class="flex w-full justify-end gap-2 lg:w-auto lg:shrink-0"');
});
it('uses sans font for the developer bulk environment variable editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
+21 -4
View File
@@ -27,9 +27,9 @@ it('rate limits repeated forgot password attempts from the same ip', function ()
it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () {
$emails = [
'ke.vinmcfadden+one@btinternet.com',
'kevin.mcfadden+two@btinternet.com',
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
'ke.vinmcfadden+one@gmail.com',
'kevin.mcfadden+two@gmail.com',
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com',
];
foreach ($emails as $index => $email) {
@@ -42,7 +42,24 @@ it('rate limits dotted plus-address forgot password variants of the same email i
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
->post('/forgot-password', [
'email' => 'k.evin.mcfadden+four@btinternet.com',
'email' => 'k.evin.mcfadden+four@gmail.com',
])
->assertTooManyRequests();
});
it('keeps distinct dotted and plus-addressed mailboxes in separate forgot password buckets on ordinary domains', function () {
$emails = [
'john.smith@example.com',
'johnsmith@example.com',
'johnsmith+one@example.com',
'johnsmith+two@example.com',
];
foreach ($emails as $index => $email) {
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 120)])
->post('/forgot-password', [
'email' => $email,
])
->assertSessionHasNoErrors();
}
});
@@ -16,6 +16,8 @@ uses(RefreshDatabase::class);
function createApplicationForAdvancedStopGracePeriodTest(): Application
{
$team = Team::factory()->create();
$team->members()->attach(auth()->id(), ['role' => 'owner']);
session(['currentTeam' => $team]);
$server = Server::factory()->create(['team_id' => $team->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
@@ -43,7 +45,8 @@ it('saves a valid stop grace period', function () {
->set('stopGracePeriod', '300')
->call('saveStopGracePeriod')
->assertHasNoErrors()
->assertDispatched('success');
->assertDispatched('success')
->assertDispatched('configurationChanged');
expect($application->settings()->first()->stop_grace_period)->toBe(300);
});
@@ -0,0 +1,44 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All as EnvironmentVariableAll;
use App\Livewire\Project\Shared\HealthChecks;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create(['environment_id' => $environment->id])->refresh();
});
it('refreshes configuration changes after health check saves', function (string $method) {
Livewire::test(HealthChecks::class, ['resource' => $this->application])
->call($method)
->assertHasNoErrors()
->assertDispatched('configurationChanged');
})->with(['instantSave', 'submit', 'toggleHealthcheck']);
it('refreshes configuration changes after environment variable settings are saved', function () {
Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application])
->set('use_build_secrets', true)
->call('instantSave')
->assertHasNoErrors()
->assertDispatched('configurationChanged');
});
@@ -0,0 +1,36 @@
<?php
use App\Livewire\Project\Application\Swarm;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('dispatches configuration changed when Swarm settings are saved', function (string $method) {
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
$server = Server::factory()->create(['team_id' => $team->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
'swarm_replicas' => 1,
]);
$this->actingAs($user);
Livewire::test(Swarm::class, ['application' => $application])
->set('isSwarmOnlyWorkerNodes', false)
->call($method)
->assertHasNoErrors()
->assertDispatched('configurationChanged');
})->with(['instantSave', 'submit']);
@@ -118,4 +118,24 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
->assertSee('nixpacks.toml')
->assertDontSee('railpack.json');
});
test('saving general settings refreshes configuration changes when label reset is disabled', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
$application->settings->update(['is_container_label_readonly_enabled' => false]);
Livewire::test(General::class, ['application' => $application->refresh()])
->set('isPreserveRepositoryEnabled', true)
->call('instantSave')
->assertHasNoErrors()
->assertDispatched('configurationChanged');
});
});
+11
View File
@@ -100,6 +100,17 @@ test('MCP endpoint returns 403 when the token team has MCP disabled', function (
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
});
test('MCP endpoint is enabled for teams by default', function () {
$defaultTeam = Team::factory()->create();
$this->user->teams()->attach($defaultTeam->id, ['role' => 'owner']);
session(['currentTeam' => $defaultTeam]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpListTools($token);
$response->assertOk();
});
test('MCP endpoint rejects unauthenticated requests', function () {
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(401);
+4 -1
View File
@@ -20,7 +20,10 @@ use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -126,3 +126,112 @@ function createPushUpdatePostgresql(Team $team, array $attributes = []): Standal
return $database;
}
test('partial sentinel snapshots do not mark missing databases as exited', function () {
$team = Team::factory()->create();
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
'is_public' => true,
]);
$server = $database->destination->server;
Queue::fake();
$data = [
'snapshot' => [
'version' => 1,
'complete' => false,
],
'containers' => [
[
'name' => 'unrelated-container',
'state' => 'running',
'health_status' => 'healthy',
'labels' => [
'coolify.managed' => 'true',
],
],
],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
$database->refresh();
expect($database->status)->toBe('running:healthy');
});
test('partial sentinel snapshots apply directly observed database status updates', function () {
$team = Team::factory()->create();
$database = createPushUpdatePostgresql($team, [
'status' => 'exited',
]);
$server = $database->destination->server;
Queue::fake();
$data = [
'snapshot' => [
'version' => 1,
'complete' => false,
],
'containers' => [
[
'name' => $database->uuid,
'state' => 'running',
'health_status' => 'healthy',
'labels' => [
'coolify.managed' => 'true',
'coolify.type' => 'database',
'com.docker.compose.service' => $database->uuid,
],
],
],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
$database->refresh();
expect($database->status)->toBe('running:healthy');
});
test('partial sentinel snapshots do not trigger missing tcp proxy recovery', function () {
$team = Team::factory()->create();
$database = createPushUpdatePostgresql($team, [
'status' => 'exited',
'is_public' => true,
]);
$server = $database->destination->server;
Queue::fake();
$data = [
'snapshot' => [
'version' => 1,
'complete' => false,
],
'containers' => [
[
'name' => $database->uuid,
'state' => 'running',
'health_status' => 'healthy',
'labels' => [
'coolify.managed' => 'true',
'coolify.type' => 'database',
'com.docker.compose.service' => $database->uuid,
],
],
],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
$database->refresh();
expect($database->status)->toBe('running:healthy');
Queue::assertNothingPushed();
});
+29 -4
View File
@@ -3,6 +3,7 @@
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
uses(RefreshDatabase::class);
@@ -42,9 +43,9 @@ it('rate limits repeated registration attempts from the same ip', function () {
it('rate limits dotted plus-address variants of the same email identity across ips', function () {
$emails = [
'ke.vinmcfadden+one@btinternet.com',
'kevin.mcfadden+two@btinternet.com',
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
'ke.vinmcfadden+one@gmail.com',
'kevin.mcfadden+two@gmail.com',
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com',
];
foreach ($emails as $index => $email) {
@@ -64,9 +65,33 @@ it('rate limits dotted plus-address variants of the same email identity across i
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
->post('/register', [
'name' => 'Blocked User',
'email' => 'k.evin.mcfadden+four@btinternet.com',
'email' => 'k.evin.mcfadden+four@gmail.com',
'password' => 'Password1!@',
'password_confirmation' => 'Password1!@',
])
->assertTooManyRequests();
});
it('keeps distinct dotted and plus-addressed mailboxes in separate rate limit buckets on ordinary domains', function () {
$registrationIpKey = 'registration:ip:'.sha1('127.0.0.1');
$emails = [
'john.smith@example.com',
'johnsmith@example.com',
'johnsmith+one@example.com',
'johnsmith+two@example.com',
];
foreach ($emails as $index => $email) {
$this->post('/register', [
'name' => "Distinct User {$index}",
'email' => $email,
'password' => 'Password1!@',
'password_confirmation' => 'Password1!@',
])
->assertRedirect();
auth()->logout();
$this->flushSession();
RateLimiter::clear($registrationIpKey);
}
});
+440
View File
@@ -0,0 +1,440 @@
<?php
use App\Livewire\Project\CloneMe;
use App\Livewire\Project\New\Select as ResourceSelect;
use App\Livewire\Project\New\SimpleDockerfile;
use App\Livewire\Project\Shared\ResourceOperations;
use App\Livewire\Server\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('resource-hosting-test', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => false,
'is_swarm_worker' => false,
'force_disabled' => false,
]);
$this->server->refresh()->load('settings');
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
afterEach(function () {
Server::flushIdentityMap();
});
function resourceHostingApiHeaders(string $token): array
{
return [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
];
}
function createResourceHostingTestApplication(object $test): Application
{
return Application::factory()->create([
'environment_id' => $test->environment->id,
'destination_id' => $test->destination->id,
'destination_type' => $test->destination->getMorphClass(),
]);
}
test('only eligible deployment servers can host resources', function () {
expect($this->server->canHostResources())->toBeTrue();
$this->server->settings->update(['is_build_server' => true]);
expect($this->server->fresh()->canHostResources())->toBeFalse();
});
test('a populated build server can be changed back to a deployment server', function () {
createResourceHostingTestApplication($this);
$this->server->settings()->update(['is_build_server' => true]);
Livewire::actingAs($this->user)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSet('isBuildServer', true)
->assertSet('isBuildServerLocked', false)
->set('isBuildServer', false)
->assertHasNoErrors();
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse();
});
test('a populated deployment server cannot be changed into a build server', function () {
createResourceHostingTestApplication($this);
Livewire::actingAs($this->user)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSet('isBuildServer', false)
->assertSet('isBuildServerLocked', true)
->set('isBuildServer', true)
->assertSet('isBuildServer', false);
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse();
});
test('resource selection keeps excluded build servers visible for explanation', function () {
$this->actingAs($this->user);
$buildServer = Server::factory()->create(['team_id' => $this->team->id]);
$buildServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
'is_swarm_worker' => false,
'force_disabled' => false,
]);
$component = new ResourceSelect;
$component->loadServers();
expect($component->servers->pluck('id'))->toContain($this->server->id)
->not->toContain($buildServer->id)
->and(Server::isUsableBuildServer()->pluck('id'))->toContain($buildServer->id)
->not->toContain($this->server->id)
->and($component->buildServers->pluck('id'))->toContain($buildServer->id)
->and($component->allServers->pluck('id'))->toContain($this->server->id, $buildServer->id);
});
test('resource selection does not show the empty server message when only build servers are available', function () {
$html = Blade::render(
file_get_contents(resource_path('views/livewire/project/new/select.blade.php')),
[
'current_step' => 'servers',
'onlyBuildServerAvailable' => true,
'servers' => collect(),
'buildServers' => collect(),
],
);
expect($html)
->toContain('Only build servers are available')
->not->toContain('No validated & reachable servers found');
});
test('application API rejects build servers', function () {
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/dockerimage', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'latest',
'ports_exposes' => '80',
'instant_deploy' => false,
])
->assertUnprocessable()
->assertInvalid(['server_uuid']);
expect(Application::count())->toBe(0);
});
test('database API rejects build servers', function () {
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->postJson('/api/v1/databases/postgresql', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'instant_deploy' => false,
])
->assertUnprocessable()
->assertInvalid(['server_uuid']);
});
test('service API rejects build servers', function () {
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->postJson('/api/v1/services', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:latest"),
'instant_deploy' => false,
])
->assertUnprocessable()
->assertInvalid(['server_uuid']);
});
test('server API rejects enabling build mode when resources exist', function () {
createResourceHostingTestApplication($this);
$originalName = $this->server->name;
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->patchJson('/api/v1/servers/'.$this->server->uuid, [
'is_build_server' => true,
'name' => 'should-not-be-saved',
])
->assertUnprocessable()
->assertInvalid(['is_build_server']);
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse()
->and($this->server->fresh()->name)->toBe($originalName);
});
test('server API allows keeping build mode enabled when resources exist', function () {
createResourceHostingTestApplication($this);
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->patchJson('/api/v1/servers/'.$this->server->uuid, [
'is_build_server' => true,
])
->assertCreated();
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeTrue();
});
test('server API allows disabling build mode when resources exist', function () {
createResourceHostingTestApplication($this);
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->patchJson('/api/v1/servers/'.$this->server->uuid, [
'is_build_server' => false,
'name' => 'Deployment Server',
])
->assertCreated();
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse()
->and($this->server->fresh()->name)->toBe('Deployment Server');
});
test('resource APIs still accept deployment servers', function () {
$headers = resourceHostingApiHeaders($this->bearerToken);
$this->withHeaders($headers)
->postJson('/api/v1/applications/dockerimage', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'latest',
'ports_exposes' => '80',
'instant_deploy' => false,
])
->assertCreated();
$this->withHeaders($headers)
->postJson('/api/v1/databases/postgresql', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'instant_deploy' => false,
])
->assertCreated();
$this->withHeaders($headers)
->postJson('/api/v1/services', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:latest"),
'instant_deploy' => false,
])
->assertCreated();
});
test('a crafted web request cannot create a resource on a build server', function () {
$this->actingAs($this->user);
$this->server->settings()->update(['is_build_server' => true]);
$url = route('project.resource.create', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
]).'?type=postgresql&destination='.$this->destination->uuid.'&server_id='.$this->server->id.'&database_image=postgres:16-alpine';
$this->get($url)->assertRedirectToRoute('dashboard');
expect(StandalonePostgresql::count())->toBe(0);
});
test('a manipulated resource form cannot submit to a build server', function () {
$this->actingAs($this->user);
$this->server->settings()->update(['is_build_server' => true]);
$routeParameters = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
];
expect(fn () => Livewire::withUrlParams(['destination' => $this->destination->uuid])
->test(SimpleDockerfile::class, $routeParameters)
->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n")
->call('submit'))
->toThrow(Exception::class, 'Destination not found.');
expect(Application::count())->toBe(0);
});
test('a manipulated project clone cannot target a build server', function () {
$this->actingAs($this->user);
$this->server->settings()->update(['is_build_server' => true]);
$projectCount = Project::count();
Livewire::test(CloneMe::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('selectedDestination', $this->destination->uuid)
->set('newName', 'blocked-clone')
->call('clone', 'project');
expect(Project::count())->toBe($projectCount);
});
test('project clone resolves overlapping destination ids by uuid', function () {
$this->actingAs($this->user);
createResourceHostingTestApplication($this);
$swarmDestination = SwarmDocker::create([
'server_id' => $this->server->id,
'name' => 'Swarm destination',
'network' => 'swarm-network',
]);
expect($swarmDestination->id)->toBe($this->destination->id);
Livewire::test(CloneMe::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('selectedDestination', $swarmDestination->uuid)
->set('newName', 'swarm-clone')
->call('clone', 'project')
->assertNotDispatched('error');
$clonedApplication = Project::where('name', 'swarm-clone')
->firstOrFail()
->environments()
->where('name', $this->environment->name)
->firstOrFail()
->applications()
->firstOrFail();
expect($clonedApplication->destination_id)->toBe($swarmDestination->id)
->and($clonedApplication->destination_type)->toBe(SwarmDocker::class)
->and($clonedApplication->destination->is($swarmDestination))->toBeTrue();
});
test('project clone assigns services to the selected server', function () {
$this->actingAs($this->user);
Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$targetServer = Server::factory()->create(['team_id' => $this->team->id]);
$targetServer->settings()->update(['is_build_server' => false]);
$targetDestination = StandaloneDocker::where('server_id', $targetServer->id)->firstOrFail();
Livewire::test(CloneMe::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('selectedDestination', $targetDestination->uuid)
->set('newName', 'service-clone')
->call('clone', 'project')
->assertNotDispatched('error');
$clonedService = Project::where('name', 'service-clone')
->firstOrFail()
->environments()
->where('name', $this->environment->name)
->firstOrFail()
->services()
->firstOrFail();
expect($clonedService->server_id)->toBe($targetServer->id)
->and($clonedService->destination_id)->toBe($targetDestination->id)
->and($targetServer->fresh()->isEmpty())->toBeFalse();
});
test('a manipulated clone request cannot target a build server', function () {
$this->actingAs($this->user);
$source = createResourceHostingTestApplication($this);
$buildServer = Server::factory()->create(['team_id' => $this->team->id]);
$buildServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
$buildDestination = StandaloneDocker::where('server_id', $buildServer->id)->firstOrFail();
Livewire::test(ResourceOperations::class, ['resource' => $source])
->call('cloneTo', $buildDestination->uuid)
->assertHasErrors(['destination_id']);
expect(Application::count())->toBe(1);
});
test('resource clone resolves overlapping destination ids by uuid', function () {
$this->actingAs($this->user);
$source = createResourceHostingTestApplication($this);
$swarmDestination = SwarmDocker::create([
'server_id' => $this->server->id,
'name' => 'Swarm clone target',
'network' => 'swarm-clone-target',
]);
expect($swarmDestination->id)->toBe($this->destination->id);
Livewire::test(ResourceOperations::class, ['resource' => $source])
->call('cloneTo', $swarmDestination->uuid);
$clone = Application::whereKeyNot($source->id)->firstOrFail();
expect($clone->destination_id)->toBe($swarmDestination->id)
->and($clone->destination_type)->toBe(SwarmDocker::class)
->and($clone->destination->is($swarmDestination))->toBeTrue();
});
test('resource operations explains why build servers cannot be clone targets', function () {
$this->actingAs($this->user);
$source = createResourceHostingTestApplication($this);
$buildServer = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'Dedicated Builder']);
$buildServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
Livewire::test(ResourceOperations::class, ['resource' => $source])
->assertSet('buildServers', fn ($servers) => $servers->contains('id', $buildServer->id))
->assertSee('Dedicated Builder')
->assertSee('Build server — cannot host resources');
});
+10
View File
@@ -60,3 +60,13 @@ it('runs v5 agent token rotation often enough for the default ttl and refresh th
expect($event->expression)->toBe('*/15 * * * *');
expect($event->onOneServer)->toBeTrue();
});
it('does not schedule Stripe subscription reconciliation automatically', function () {
$schedule = app(Schedule::class);
$event = collect($schedule->events())->first(
fn ($event) => str_contains((string) $event->description, 'SyncStripeSubscriptions')
);
expect($event)->toBeNull();
});
@@ -196,3 +196,24 @@ it('rejects an invalid token without dispatching', function () use ($running) {
Queue::assertNotPushed(PushServerUpdateJob::class);
});
it('dispatches a complete snapshot after an identical partial snapshot', function () use ($running) {
$partialPayload = sentinelPayload($running()) + [
'snapshot' => [
'version' => 1,
'complete' => false,
],
];
$completePayload = sentinelPayload($running()) + [
'snapshot' => [
'version' => 1,
'complete' => true,
],
];
pushSentinel($this->token, $partialPayload)->assertOk();
pushSentinel($this->token, $completePayload)->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
@@ -0,0 +1,118 @@
<?php
use App\Livewire\Server\New\ByHetzner;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->hetznerToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'token' => 'test-hetzner-token',
'name' => 'Test Hetzner Token',
]);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
});
it('persists the server with a placeholder IP when Hetzner has not assigned one yet', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
], 200),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 777,
'status' => 'initializing',
'public_net' => [
'ipv4' => null,
'ipv6' => null,
],
],
], 201),
]);
Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
->assertSet('current_step', 2)
->set('server_name', 'test-hetzner-server')
->set('selected_location', 'fsn1')
->set('selected_server_type', 'cx22')
->set('selected_image', 114690387)
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors();
$this->assertDatabaseHas('servers', [
'name' => 'test-hetzner-server',
'ip' => Server::PLACEHOLDER_IP,
'team_id' => $this->team->id,
'cloud_provider_token_id' => $this->hetznerToken->id,
'hetzner_server_id' => '777',
'hetzner_server_status' => 'initializing',
]);
});
it('creates the server with the real IP when Hetzner assigns one immediately', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
], 200),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 777,
'status' => 'running',
'public_net' => [
'ipv4' => ['ip' => '203.0.113.30'],
],
],
], 201),
]);
Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
->assertSet('current_step', 2)
->set('server_name', 'test-hetzner-server')
->set('selected_location', 'fsn1')
->set('selected_server_type', 'cx22')
->set('selected_image', 114690387)
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors();
$this->assertDatabaseHas('servers', [
'name' => 'test-hetzner-server',
'ip' => '203.0.113.30',
'hetzner_server_id' => '777',
'hetzner_server_status' => 'running',
]);
});
@@ -0,0 +1,149 @@
<?php
use App\Jobs\ServerCloudProviderStatusCheckJob;
use App\Jobs\ServerConnectionCheckJob;
use App\Jobs\ServerManagerJob;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
function createCloudServerForStatusCheckJobTest(string $provider, array $attributes = []): Server
{
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$token = CloudProviderToken::create([
'team_id' => $team->id,
'provider' => $provider,
'token' => "test-{$provider}-token",
'name' => ucfirst($provider),
]);
return Server::factory()->create(array_merge([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'ip' => Server::PLACEHOLDER_IP,
], $attributes));
}
beforeEach(function () {
InstanceSettings::forceCreate([
'id' => 0,
'instance_timezone' => 'UTC',
]);
Carbon::setTestNow('2026-07-11 12:00:00');
});
afterEach(function () {
Carbon::setTestNow();
});
it('syncs provider state for a placeholder server without scheduling SSH', function () {
$server = createCloudServerForStatusCheckJobTest('digitalocean', [
'digitalocean_droplet_id' => 987,
'digitalocean_droplet_status' => 'new',
]);
Queue::fake();
(new ServerManagerJob)->handle();
Queue::assertPushed(ServerCloudProviderStatusCheckJob::class, fn (ServerCloudProviderStatusCheckJob $job) => $job->server->is($server));
Queue::assertNotPushed(ServerConnectionCheckJob::class);
});
it('backfills a DigitalOcean placeholder without changing reachability', function () {
$server = createCloudServerForStatusCheckJobTest('digitalocean', [
'digitalocean_droplet_id' => 987,
'digitalocean_droplet_status' => 'new',
]);
$server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
'droplet' => [
'id' => 987,
'status' => 'active',
'networks' => [
'v4' => [
['type' => 'public', 'ip_address' => '203.0.113.10'],
],
],
],
]),
]);
(new ServerCloudProviderStatusCheckJob($server))->handle();
$server->refresh();
expect($server->ip)->toBe('203.0.113.10')
->and($server->digitalocean_droplet_status)->toBe('active')
->and((bool) $server->settings->fresh()->is_reachable)->toBeTrue()
->and((bool) $server->settings->fresh()->is_usable)->toBeTrue();
});
it('backfills a Hetzner placeholder without changing reachability', function () {
$server = createCloudServerForStatusCheckJobTest('hetzner', [
'hetzner_server_id' => 123,
'hetzner_server_status' => 'starting',
]);
$server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
Http::fake([
'https://api.hetzner.cloud/v1/servers/123' => Http::response([
'server' => [
'id' => 123,
'status' => 'running',
'public_net' => [
'ipv4' => ['ip' => '198.51.100.20'],
],
],
]),
]);
(new ServerCloudProviderStatusCheckJob($server))->handle();
$server->refresh();
expect($server->ip)->toBe('198.51.100.20')
->and($server->hetzner_server_status)->toBe('running')
->and((bool) $server->settings->fresh()->is_reachable)->toBeTrue()
->and((bool) $server->settings->fresh()->is_usable)->toBeTrue();
});
it('only calls the API matching the cloud provider token', function () {
$server = createCloudServerForStatusCheckJobTest('digitalocean', [
'vultr_instance_id' => 'unrelated-vultr-instance',
'digitalocean_droplet_id' => 987,
'digitalocean_droplet_status' => 'new',
]);
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
'droplet' => [
'id' => 987,
'status' => 'active',
'networks' => ['v4' => []],
],
]),
'https://api.vultr.com/*' => Http::response(status: 500),
]);
(new ServerCloudProviderStatusCheckJob($server))->handle();
Http::assertSentCount(1);
Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets/987');
});
@@ -0,0 +1,80 @@
<?php
use App\Jobs\ServerConnectionCheckJob;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
function createServerForConnectionIsolationTest(array $attributes = []): Server
{
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
return Server::factory()->create(array_merge([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
], $attributes));
}
beforeEach(function () {
Storage::fake('ssh-keys');
});
it('never attempts SSH to a placeholder address', function (string $placeholderIp) {
$server = createServerForConnectionIsolationTest(['ip' => $placeholderIp]);
$server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
Process::fake();
(new ServerConnectionCheckJob($server, disableMux: false))->handle();
Process::assertNothingRan();
expect($server->fresh()->unreachable_count)->toBe(0)
->and((bool) $server->settings->fresh()->is_reachable)->toBeTrue()
->and((bool) $server->settings->fresh()->is_usable)->toBeTrue();
})->with([
'reserved provisioning address' => Server::PLACEHOLDER_IP,
'unspecified IPv4 address' => '0.0.0.0',
'unspecified IPv6 address' => '::',
]);
it('does not call cloud provider APIs during an SSH connection check', function () {
$server = createServerForConnectionIsolationTest(['ip' => '203.0.113.10']);
$token = CloudProviderToken::create([
'team_id' => $server->team_id,
'provider' => 'vultr',
'token' => 'test-vultr-token',
'name' => 'Vultr',
]);
$server->update([
'cloud_provider_token_id' => $token->id,
'vultr_instance_id' => 'instance-1',
'vultr_instance_status' => 'active',
]);
Http::fake([
'https://api.vultr.com/*' => Http::response([
'instance' => ['id' => 'instance-1', 'status' => 'active'],
]),
]);
Process::fake([
'*' => Process::result(
output: '{"Server":{"Version":"27.0.0"}}',
exitCode: 0,
),
]);
(new ServerConnectionCheckJob($server->fresh(), disableMux: false))->handle();
Http::assertNothingSent();
});
+165
View File
@@ -0,0 +1,165 @@
<?php
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->bearerToken = createBearerTokenForServiceDatabasesApiTest($this->user, $this->team);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createBearerTokenForServiceDatabasesApiTest(User $user, Team $team, array $abilities = ['*']): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function createServiceWithDatabaseForApiTest(object $context): object
{
$service = Service::factory()->create([
'environment_id' => $context->environment->id,
'server_id' => $context->server->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
'docker_compose_raw' => "services:\n postgres:\n image: postgres:17-alpine\n",
]);
$serviceDatabase = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'postgres',
'service_id' => $service->id,
'image' => 'postgres:17-alpine',
'is_public' => false,
]);
return (object) ['service' => $service, 'serviceDatabase' => $serviceDatabase];
}
test('lists and gets databases belonging to a service', function () {
$context = createServiceWithDatabaseForApiTest($this);
$headers = ['Authorization' => 'Bearer '.$this->bearerToken];
$this->withHeaders($headers)
->getJson("/api/v1/services/{$context->service->uuid}/databases")
->assertOk()
->assertJsonFragment(['uuid' => $context->serviceDatabase->uuid]);
$this->withHeaders($headers)
->getJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}")
->assertOk()
->assertJsonFragment(['name' => 'postgres']);
});
test('does not return a database from another service', function () {
$context = createServiceWithDatabaseForApiTest($this);
$otherContext = createServiceWithDatabaseForApiTest($this);
$this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken])
->getJson("/api/v1/services/{$context->service->uuid}/databases/{$otherContext->serviceDatabase->uuid}")
->assertNotFound()
->assertJsonFragment(['message' => 'Service database not found.']);
});
test('updates supported service database fields and rejects unknown fields', function () {
$context = createServiceWithDatabaseForApiTest($this);
$url = "/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}";
$headers = ['Authorization' => 'Bearer '.$this->bearerToken];
$this->withHeaders($headers)->patchJson($url, [
'human_name' => 'Primary database',
'description' => 'Stores production data',
'public_port' => 5432,
'public_port_timeout' => 1800,
'exclude_from_status' => true,
])->assertOk()->assertJsonFragment([
'human_name' => 'Primary database',
'public_port' => 5432,
]);
$context->serviceDatabase->refresh();
expect($context->serviceDatabase->human_name)->toBe('Primary database')
->and($context->serviceDatabase->public_port_timeout)->toBe(1800)
->and($context->serviceDatabase->exclude_from_status)->toBeTrue();
$this->withHeaders($headers)
->patchJson($url, ['unsupported' => true])
->assertUnprocessable()
->assertJsonPath('errors.unsupported.0', 'This field is not allowed.');
});
test('queues lifecycle actions for a service database', function () {
$context = createServiceWithDatabaseForApiTest($this);
$baseUrl = "/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}";
$headers = ['Authorization' => 'Bearer '.$this->bearerToken];
$this->withHeaders($headers)->postJson("{$baseUrl}/start?latest=1&force=1")
->assertOk()
->assertJsonFragment(['message' => 'Service database deploy request queued.']);
DeployServiceApplication::assertPushed();
$this->withHeaders($headers)->postJson("{$baseUrl}/restart")
->assertOk()
->assertJsonFragment(['message' => 'Service database restart request queued.']);
RestartServiceApplication::assertPushed();
$this->withHeaders($headers)->postJson("{$baseUrl}/stop")
->assertOk()
->assertJsonFragment(['message' => 'Service database stop request queued.']);
StopServiceApplication::assertPushed();
});
test('requires deploy ability for service database lifecycle actions', function () {
$context = createServiceWithDatabaseForApiTest($this);
$writeToken = createBearerTokenForServiceDatabasesApiTest($this->user, $this->team, ['write']);
$this->withHeaders(['Authorization' => 'Bearer '.$writeToken])
->postJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}/restart")
->assertForbidden();
});
test('returns a clear error when logs cannot be read from the server', function () {
$context = createServiceWithDatabaseForApiTest($this);
$this->server->settings->update(['is_reachable' => false]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken])
->getJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}/logs")
->assertBadRequest()
->assertJsonFragment(['message' => 'Server is not functional.']);
});
+2 -2
View File
@@ -15,8 +15,8 @@ function serviceExtraFieldsTestServiceWithApplicationImage(string $image): Servi
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create();
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$service = Service::factory()->create([
'environment_id' => $environment->id,
@@ -0,0 +1,296 @@
<?php
use App\Actions\Stripe\SyncStripeSubscriptions;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Collection as StripeCollection;
use Stripe\Service\CustomerService;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
$this->team = Team::factory()->create();
$this->subscription = Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_stale',
'stripe_customer_id' => 'cus_stale',
'stripe_invoice_paid' => true,
]);
});
afterEach(function () {
SyncStripeSubscriptions::clearFake();
});
function stripeSubscriptionCollection(array $subscriptions = []): StripeCollection
{
return StripeCollection::constructFrom([
'data' => $subscriptions,
'has_more' => false,
'url' => '/v1/subscriptions',
]);
}
test('fix mode fully ends a locally active subscription with an invalid Stripe status', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'unreachable_count' => 0,
'unreachable_notification_sent' => false,
]);
$stripe = Mockery::mock(StripeClient::class);
$subscriptions = Mockery::mock(SubscriptionService::class);
$customers = Mockery::mock(CustomerService::class);
$stripe->subscriptions = $subscriptions;
$stripe->customers = $customers;
$subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection());
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
'status' => 'unpaid',
'customer' => 'cus_stale',
]);
$customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable'));
$this->instance(StripeClient::class, $stripe);
$progress = [];
SyncStripeSubscriptions::run(
fix: true,
onProgress: function (string $stage, int $current, ?int $total) use (&$progress): void {
$progress[] = [$stage, $current, $total];
},
);
$this->subscription->refresh();
$server->refresh();
expect($this->subscription->stripe_invoice_paid)->toBeFalsy()
->and($this->subscription->stripe_subscription_id)->toBeNull()
->and($server->unreachable_count)->toBe(3)
->and($server->unreachable_notification_sent)->toBeTrue()
->and($progress)->toContain(['checking', 0, 1])
->and($progress)->toContain(['checking', 1, 1]);
});
test('a same-email subscription not linked to the team is left for manual review', function () {
$stripe = Mockery::mock(StripeClient::class);
$subscriptions = Mockery::mock(SubscriptionService::class);
$customers = Mockery::mock(CustomerService::class);
$stripe->subscriptions = $subscriptions;
$stripe->customers = $customers;
$subscriptions->shouldReceive('all')
->with(Mockery::on(fn (array $parameters) => isset($parameters['status'])))
->twice()
->andReturn(stripeSubscriptionCollection());
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
'status' => 'canceled',
'customer' => 'cus_stale',
]);
$customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [
'email' => 'customer@example.com',
]);
$customers->shouldReceive('all')->andReturn((object) [
'data' => [(object) ['id' => 'cus_active']],
]);
$subscriptions->shouldReceive('all')->with([
'customer' => 'cus_active',
'limit' => 10,
])->andReturn((object) [
'data' => [(object) [
'id' => 'sub_active_elsewhere',
'status' => 'active',
]],
]);
$this->instance(StripeClient::class, $stripe);
$result = SyncStripeSubscriptions::run(fix: true);
$this->subscription->refresh();
expect($result['resubscribed'])->toHaveCount(1)
->and($result['discrepancies'])->toHaveCount(1)
->and($result['discrepancies'][0]['resolution'])->toBe('manual_review')
->and($this->subscription->stripe_invoice_paid)->toBeTruthy()
->and($this->subscription->stripe_subscription_id)->toBe('sub_stale');
});
test('a stale local subscription is deleted when its team has a valid replacement', function () {
$replacement = Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_active_elsewhere',
'stripe_customer_id' => 'cus_active',
'stripe_invoice_paid' => true,
]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
$stripe = Mockery::mock(StripeClient::class);
$subscriptions = Mockery::mock(SubscriptionService::class);
$customers = Mockery::mock(CustomerService::class);
$stripe->subscriptions = $subscriptions;
$stripe->customers = $customers;
$subscriptions->shouldReceive('all')
->with(['status' => 'active', 'limit' => 100])
->andReturn(stripeSubscriptionCollection([
['id' => 'sub_active_elsewhere'],
]));
$subscriptions->shouldReceive('all')
->with(['status' => 'past_due', 'limit' => 100])
->andReturn(stripeSubscriptionCollection());
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
'status' => 'canceled',
'customer' => 'cus_stale',
]);
$customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [
'email' => 'customer@example.com',
]);
$customers->shouldReceive('all')->andReturn((object) [
'data' => [(object) ['id' => 'cus_active']],
]);
$subscriptions->shouldReceive('all')->with([
'customer' => 'cus_active',
'limit' => 10,
])->andReturn((object) [
'data' => [(object) [
'id' => 'sub_active_elsewhere',
'status' => 'active',
]],
]);
$this->instance(StripeClient::class, $stripe);
$result = SyncStripeSubscriptions::run(fix: true);
expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale')
->and($this->subscription->fresh())->toBeNull()
->and($replacement->fresh())->not->toBeNull()
->and($replacement->fresh()->stripe_invoice_paid)->toBeTruthy()
->and($server->settings->fresh()->is_reachable)->toBeTruthy()
->and($server->settings->fresh()->is_usable)->toBeTruthy();
});
test('a stale duplicate is deleted when its team already has an inactive subscription row', function () {
$inactiveSubscription = Subscription::create([
'team_id' => $this->team->id,
'stripe_customer_id' => 'cus_stale',
'stripe_invoice_paid' => false,
]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
$stripe = Mockery::mock(StripeClient::class);
$subscriptions = Mockery::mock(SubscriptionService::class);
$customers = Mockery::mock(CustomerService::class);
$stripe->subscriptions = $subscriptions;
$stripe->customers = $customers;
$subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection());
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
'status' => 'canceled',
'customer' => 'cus_stale',
]);
$customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable'));
$this->instance(StripeClient::class, $stripe);
$result = SyncStripeSubscriptions::run(fix: true);
expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale')
->and($this->subscription->fresh())->toBeNull()
->and($inactiveSubscription->fresh())->not->toBeNull()
->and($server->settings->fresh()->is_reachable)->toBeFalsy()
->and($server->settings->fresh()->is_usable)->toBeFalsy();
});
test('valid Stripe subscriptions remain active', function (string $status) {
$stripe = Mockery::mock(StripeClient::class);
$subscriptions = Mockery::mock(SubscriptionService::class);
$stripe->subscriptions = $subscriptions;
$subscriptions->shouldReceive('all')
->with(['status' => $status, 'limit' => 100])
->andReturn(stripeSubscriptionCollection([
['id' => 'sub_stale'],
]));
$subscriptions->shouldReceive('all')
->with(['status' => $status === 'active' ? 'past_due' : 'active', 'limit' => 100])
->andReturn(stripeSubscriptionCollection());
$subscriptions->shouldNotReceive('retrieve');
$this->instance(StripeClient::class, $stripe);
$result = SyncStripeSubscriptions::run(fix: true);
$this->subscription->refresh();
expect($result['discrepancies'])->toBeEmpty()
->and($this->subscription->stripe_invoice_paid)->toBeTruthy()
->and($this->subscription->stripe_subscription_id)->toBe('sub_stale');
})->with(['active', 'past_due']);
test('the terminal command runs the reconciliation action synchronously', function () {
SyncStripeSubscriptions::shouldRun()
->once()
->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === false && $onProgress instanceof Closure)
->andReturnUsing(function (bool $fix, Closure $onProgress): array {
$onProgress('checking', 2, 10);
return [
'total_checked' => 0,
'discrepancies' => [],
'resubscribed' => [],
'errors' => [],
'fixed' => false,
];
});
$this->artisan('cloud:sync-stripe-subscriptions')
->expectsOutputToContain('Checking stale subscriptions against Stripe... 2/10')
->expectsOutput('Total subscriptions checked: 0')
->assertSuccessful();
});
test('the terminal command passes the fix option to the reconciliation action', function () {
SyncStripeSubscriptions::shouldRun()
->once()
->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === true && $onProgress instanceof Closure)
->andReturn([
'total_checked' => 1,
'discrepancies' => [[
'subscription_id' => 1,
'team_id' => 2,
'stripe_subscription_id' => 'sub_stale',
'stripe_status' => 'canceled',
'resolution' => 'manual_review',
]],
'resubscribed' => [],
'errors' => [],
'fixed' => true,
'fixed_count' => 0,
'manual_review_count' => 1,
]);
$this->artisan('cloud:sync-stripe-subscriptions', ['--fix' => true])
->expectsOutput('Total subscriptions checked: 1')
->expectsOutput(' Resolution: Manual review required')
->expectsOutput('Automatic corrections applied: 0')
->expectsOutput('Skipped for manual review: 1')
->assertSuccessful();
});
@@ -1,5 +1,6 @@
<?php
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -14,3 +15,24 @@ test('subscriptionEnded does not throw when team has no subscription', function
// If we reach here, no exception was thrown
expect(true)->toBeTrue();
});
test('subscriptionEnded updates the exact subscription when duplicate rows exist', function () {
$team = Team::factory()->create();
$otherSubscription = Subscription::create([
'team_id' => $team->id,
'stripe_subscription_id' => 'sub_other',
'stripe_invoice_paid' => true,
]);
$subscriptionToEnd = Subscription::create([
'team_id' => $team->id,
'stripe_subscription_id' => 'sub_stale',
'stripe_invoice_paid' => true,
]);
$team->subscriptionEnded($subscriptionToEnd);
expect($subscriptionToEnd->fresh()->stripe_subscription_id)->toBeNull()
->and($subscriptionToEnd->fresh()->stripe_invoice_paid)->toBeFalsy()
->and($otherSubscription->fresh()->stripe_subscription_id)->toBe('sub_other')
->and($otherSubscription->fresh()->stripe_invoice_paid)->toBeTruthy();
});
+158
View File
@@ -1,11 +1,15 @@
<?php
use App\Http\Controllers\Api\VultrController;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
@@ -165,6 +169,27 @@ describe('GET /api/v1/vultr/ssh-keys', function () {
});
});
test('member read token cannot use a stored Vultr token for metadata endpoints', function (string $endpoint) {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
Http::fake();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
'Content-Type' => 'application/json',
])->getJson($endpoint.'?cloud_provider_token_uuid='.$this->vultrToken->uuid);
$response->assertForbidden();
Http::assertNothingSent();
})->with([
'/api/v1/vultr/regions',
'/api/v1/vultr/plans',
'/api/v1/vultr/os',
'/api/v1/vultr/ssh-keys',
]);
describe('POST /api/v1/servers/vultr', function () {
test('creates a Vultr server', function () {
Http::fake([
@@ -223,6 +248,11 @@ describe('POST /api/v1/servers/vultr', function () {
});
test('waits for Vultr to assign a real public IP when creation returns placeholder IP', function () {
$createdIp = null;
Server::created(function (Server $server) use (&$createdIp): void {
$createdIp = (string) $server->ip;
});
Http::fake([
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
@@ -262,6 +292,8 @@ describe('POST /api/v1/servers/vultr', function () {
$response->assertStatus(201);
$response->assertJsonFragment(['ip' => '1.2.3.4']);
expect($createdIp)->toBe(Server::PLACEHOLDER_IP);
$this->assertDatabaseHas('servers', [
'name' => 'test-server',
'ip' => '1.2.3.4',
@@ -269,6 +301,132 @@ describe('POST /api/v1/servers/vultr', function () {
]);
});
test('deletes the Vultr instance when local server persistence fails', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
], 201),
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-persistence-fails',
'main_ip' => '203.0.113.10',
'status' => 'pending',
],
], 202),
'https://api.vultr.com/v2/instances/instance-persistence-fails' => Http::response(null, 204),
]);
$eventDispatcher = Server::getEventDispatcher();
Server::setEventDispatcher(clone $eventDispatcher);
Server::created(function (): void {
throw new RuntimeException('local persistence failed');
});
try {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', [
'cloud_provider_token_id' => $this->vultrToken->uuid,
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'name' => 'persistence-fails',
'private_key_uuid' => $this->privateKey->uuid,
]);
} finally {
Server::setEventDispatcher($eventDispatcher);
}
$response->assertServerError();
$response->assertJson(['message' => 'Failed to create Vultr server.']);
$this->assertDatabaseMissing('servers', [
'vultr_instance_id' => 'instance-persistence-fails',
]);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-persistence-fails');
});
test('keeps the tracked Vultr server when public IP polling fails', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
], 201),
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-polling-fails',
'main_ip' => '0.0.0.0',
'status' => 'pending',
],
], 202),
'https://api.vultr.com/v2/instances/instance-polling-fails' => Http::response([
'error' => 'temporary polling failure',
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', [
'cloud_provider_token_id' => $this->vultrToken->uuid,
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'name' => 'polling-fails',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertCreated();
$response->assertJsonFragment([
'vultr_instance_id' => 'instance-polling-fails',
'ip' => Server::PLACEHOLDER_IP,
]);
$this->assertDatabaseHas('servers', [
'name' => 'polling-fails',
'ip' => Server::PLACEHOLDER_IP,
'team_id' => $this->team->id,
'vultr_instance_id' => 'instance-polling-fails',
'vultr_instance_status' => 'pending',
]);
Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
});
test('server creation authorizes access to the stored Vultr token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
$member->withAccessToken($member->createToken('member-all', ['*'])->accessToken);
$request = Request::create(
uri: '/api/v1/servers/vultr',
method: 'POST',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode([
'cloud_provider_token_uuid' => $this->vultrToken->uuid,
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
], JSON_THROW_ON_ERROR),
);
$request->setUserResolver(fn () => $member);
Http::fake();
expect(fn () => app(VultrController::class)->createServer($request))
->toThrow(AuthorizationException::class);
Http::assertNothingSent();
});
test('reuses existing matching Vultr SSH key', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
+93 -2
View File
@@ -4,9 +4,11 @@ use App\Livewire\Server\New\ByVultr;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
@@ -95,7 +97,7 @@ it('creates a Vultr server through the Livewire flow', function () {
'instance' => [
'id' => 'instance-1',
'label' => 'test-vultr-server',
'main_ip' => '1.2.3.4',
'main_ip' => '192.0.2.10',
'v6_main_ip' => '2001:db8::1',
'status' => 'pending',
],
@@ -115,7 +117,7 @@ it('creates a Vultr server through the Livewire flow', function () {
$this->assertDatabaseHas('servers', [
'name' => 'test-vultr-server',
'ip' => '1.2.3.4',
'ip' => '192.0.2.10',
'team_id' => $this->team->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-1',
@@ -132,6 +134,95 @@ it('creates a Vultr server through the Livewire flow', function () {
});
});
it('persists the server with a placeholder IP when Vultr has not assigned one yet', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()],
], 201),
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances/instance-1' => Http::response(['error' => 'temporary error'], 500),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'label' => 'test-vultr-server',
'main_ip' => '0.0.0.0',
'v6_main_ip' => '::',
'status' => 'pending',
],
], 202),
]);
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
->set('server_name', 'test-vultr-server')
->set('selected_region', 'ewr')
->set('selected_plan', 'vc2-1c-1gb')
->set('selected_os_id', 2284)
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors();
$this->assertDatabaseHas('servers', [
'name' => 'test-vultr-server',
'ip' => Server::PLACEHOLDER_IP,
'team_id' => $this->team->id,
'vultr_instance_id' => 'instance-1',
'vultr_instance_status' => 'pending',
]);
Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE'
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-1');
});
it('deletes the Vultr instance when local server persistence fails', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()],
], 201),
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'label' => 'persistence-fails',
'main_ip' => '192.0.2.10',
'v6_main_ip' => '2001:db8::1',
'status' => 'pending',
],
], 202),
'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204),
]);
$eventDispatcher = Server::getEventDispatcher();
Server::setEventDispatcher(clone $eventDispatcher);
Server::created(function (): void {
throw new RuntimeException('local persistence failed');
});
try {
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
->set('server_name', 'persistence-fails')
->set('selected_region', 'ewr')
->set('selected_plan', 'vc2-1c-1gb')
->set('selected_os_id', 2284)
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertDispatched('error', 'local persistence failed');
} finally {
Server::setEventDispatcher($eventDispatcher);
}
$this->assertDatabaseMissing('servers', [
'vultr_instance_id' => 'instance-1',
]);
Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE'
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-1');
});
it('requires IPv6 when public IPv4 is disabled', function () {
Livewire::test(ByVultr::class)
->set('selected_token_id', $this->vultrToken->id)
+24 -2
View File
@@ -123,6 +123,8 @@ it('updates placeholder server IP when Vultr status refresh returns assigned pub
});
it('does not overwrite an established server IP during Vultr status refresh', function () {
$this->server->update(['ip' => '198.51.100.20']);
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
@@ -135,12 +137,12 @@ it('does not overwrite an established server IP during Vultr status refresh', fu
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('checkVultrInstanceStatus', true)
->assertSet('ip', '1.2.3.4')
->assertSet('ip', '198.51.100.20')
->assertSet('vultrInstanceStatus', 'active');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'ip' => '1.2.3.4',
'ip' => '198.51.100.20',
'vultr_instance_status' => 'active',
]);
});
@@ -270,6 +272,26 @@ it('starts a Vultr instance', function () {
Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance-1/start');
});
it('prevents members from starting a Vultr instance', function () {
$this->server->update(['vultr_instance_status' => 'stopped']);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Http::fake([
'https://api.vultr.com/v2/instances/instance-1/start' => Http::response([], 204),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('startVultrInstance')
->assertDispatched('error');
expect($this->server->fresh()->vultr_instance_status)->toBe('stopped');
Http::assertNothingSent();
});
it('links a server to Vultr by matching IP', function () {
$unlinkedServer = Server::factory()->create([
'team_id' => $this->team->id,
+14
View File
@@ -18,6 +18,20 @@ it('normalizes requested log line counts', function () {
->and(normalizeLogLines('50000'))->toBe(10000);
});
it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) {
$source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
expect($source)->toContain('$lines = normalizeLogLines($request->query(\'lines\'));')
->and(normalizeLogLines($lines))->toBe($expectedLines);
})->with([
'application invalid value' => ['ServiceApplicationsController', 'invalid', 100],
'application negative value' => ['ServiceApplicationsController', '-5', 100],
'application value above limit' => ['ServiceApplicationsController', '50000', 10000],
'database invalid value' => ['ServiceDatabasesController', 'invalid', 100],
'database negative value' => ['ServiceDatabasesController', '-5', 100],
'database value above limit' => ['ServiceDatabasesController', '50000', 10000],
]);
it('parses show_timestamps query values as booleans', function () {
if (! function_exists('parseLogTimestampFlag')) {
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
@@ -3,12 +3,14 @@
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use App\Models\EnvironmentVariable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Tests\TestCase;
uses(TestCase::class);
uses(TestCase::class, RefreshDatabase::class);
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
{
@@ -373,9 +375,9 @@ it('creates an empty build-time env file for railpack when there are no generate
]));
foreach ([
'application_deployment_queue' => new class
'application_deployment_queue' => new class extends ApplicationDeploymentQueue
{
public function addLogEntry(string $message, string $type = 'info', bool $hidden = false): void {}
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false): void {}
},
'build_pack' => 'railpack',
'pull_request_id' => 0,
+27
View File
@@ -0,0 +1,27 @@
<?php
use App\Models\Application;
use OpenApi\Attributes\Property;
use OpenApi\Attributes\Schema;
it('documents application settings under the settings property', function () {
$schema = (new ReflectionClass(Application::class))
->getAttributes(Schema::class)[0]
->newInstance();
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
$settingsProperty = collect($schema->properties)
->first(fn (mixed $property): bool => $property instanceof Property
&& $property->property === 'settings');
expect($settingsProperty)
->not->toBeNull()
->and($openApi['components']['schemas']['Application']['properties'])
->toHaveKey('settings')
->not->toHaveKey('');
});
@@ -0,0 +1,36 @@
<?php
use App\Models\StandaloneClickhouse;
use App\Support\ClickhouseBackupCommand;
test('clickhouse exposes scheduled backups', function () {
expect((new StandaloneClickhouse)->isBackupSolutionAvailable())->toBeTrue();
});
test('builds a native clickhouse backup command using container credentials', function () {
$commands = ClickhouseBackupCommand::make(
containerName: 'clickhouse-uuid',
database: 'analytics',
archiveName: 'backup-uuid.zip',
backupDirectory: '/data/coolify/backups/clickhouse',
);
expect($commands)->toHaveCount(3)
->and($commands[0])->toBe("mkdir -p '/data/coolify/backups/clickhouse'")
->and($commands[1])->toContain("docker exec 'clickhouse-uuid' clickhouse-client")
->and($commands[1])->not->toContain('--password')
->and($commands[1])->toContain("BACKUP DATABASE `analytics` TO File('")
->and($commands[1])->toContain('backup-uuid.zip')
->and($commands[2])->toBe("docker cp 'clickhouse-uuid:/var/lib/clickhouse/backups/backup-uuid.zip' '/data/coolify/backups/clickhouse/backup-uuid.zip'")
->and(ClickhouseBackupCommand::cleanup('clickhouse-uuid', 'backup-uuid.zip'))
->toBe("docker exec 'clickhouse-uuid' rm -f '/var/lib/clickhouse/backups/backup-uuid.zip'");
});
test('rejects unsafe clickhouse database names', function () {
expect(fn () => ClickhouseBackupCommand::make(
containerName: 'clickhouse-uuid',
database: 'analytics`; DROP DATABASE default; --',
archiveName: 'backup-uuid.zip',
backupDirectory: '/data/coolify/backups/clickhouse',
))->toThrow(Exception::class);
});
@@ -6,6 +6,7 @@ use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -78,6 +79,192 @@ it('detects redeploy-only domain changes', function () {
->and($change['new_full_value'])->toBe($domains);
});
it('detects Docker image reference changes as redeploy-only changes', function (string $field, string $label, string $newValue) {
$application = snapshotTestApplication([
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'ghcr.io/coollabsio/shoutrrr',
'docker_registry_image_tag' => '1.3.0-rc.4',
]);
markSnapshotTestApplicationDeployed($application);
$application->update([$field => $newValue]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', $label);
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
->and($change)->not->toBeNull()
->and($change['old_display_value'])->not->toBe($change['new_display_value'])
->and($change['new_display_value'])->toBe($newValue);
})->with([
'image name' => ['docker_registry_image_name', 'Docker image', 'ghcr.io/coollabsio/coolify'],
'image tag' => ['docker_registry_image_tag', 'Docker image tag or hash', '1.3.0-rc.5'],
]);
it('detects deployment hook changes as redeploy-only changes', function (string $field, string $label, string $newValue) {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update([$field => $newValue]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('label'))->toContain($label);
})->with([
'pre-deployment command' => ['pre_deployment_command', 'Pre-deployment command', 'php artisan migrate --force'],
'pre-deployment container' => ['pre_deployment_command_container', 'Pre-deployment command container', 'web'],
'post-deployment command' => ['post_deployment_command', 'Post-deployment command', 'php artisan cache:clear'],
'post-deployment container' => ['post_deployment_command_container', 'Post-deployment command container', 'worker'],
]);
it('detects source integration changes as build changes', function (string $field, string $label, mixed $newValue) {
$application = snapshotTestApplication([
'source_id' => 1,
'source_type' => 'App\\Models\\GithubApp',
]);
markSnapshotTestApplicationDeployed($application);
$application->forceFill([$field => $newValue])->save();
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->requiresBuild())->toBeTrue()
->and(collect($diff->changes())->pluck('label'))->toContain($label);
})->with([
'source ID' => ['source_id', 'Source ID', 2],
'source type' => ['source_type', 'Source type', 'App\\Models\\GitlabApp'],
]);
it('detects build-affecting application settings', function (string $field, string $label) {
$application = snapshotTestApplication();
$application->settings->update([$field => false]);
markSnapshotTestApplicationDeployed($application);
$application->settings->update([$field => true]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->requiresBuild())->toBeTrue()
->and(collect($diff->changes())->pluck('label'))->toContain($label);
})->with([
'static site' => ['is_static', 'Static site'],
'single-page application' => ['is_spa', 'Single-page application'],
'Git submodules' => ['is_git_submodules_enabled', 'Git submodules'],
'Git LFS' => ['is_git_lfs_enabled', 'Git LFS'],
'shallow clone' => ['is_git_shallow_clone_enabled', 'Shallow clone'],
'environment variable sorting' => ['is_env_sorting_enabled', 'Sort environment variables'],
]);
it('detects runtime-affecting application settings as redeploy-only changes', function (string $field, string $label, mixed $newValue) {
$application = snapshotTestApplication();
$application->settings->update([$field => is_bool($newValue) ? ! $newValue : null]);
markSnapshotTestApplicationDeployed($application);
$application->settings->update([$field => $newValue]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('label'))->toContain($label);
})->with([
'consistent container name' => ['is_consistent_container_name_enabled', 'Consistent container name', true],
'container label escaping' => ['is_container_label_escape_enabled', 'Escape container labels', false],
'container labels read-only' => ['is_container_label_readonly_enabled', 'Read-only container labels', false],
'log drain' => ['is_log_drain_enabled', 'Log drain', true],
'Swarm worker nodes' => ['is_swarm_only_worker_nodes', 'Swarm worker nodes only', true],
'stop grace period' => ['stop_grace_period', 'Stop grace period', 45],
'preserve repository' => ['is_preserve_repository_enabled', 'Preserve repository', true],
]);
it('classifies runtime options as redeploy-only and custom nginx configuration as build-affecting', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['custom_docker_run_options' => '--init']);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->requiresBuild())->toBeFalse();
markSnapshotTestApplicationDeployed($application->refresh());
$application->update(['custom_nginx_configuration' => 'server { listen 80; }']);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->requiresBuild())->toBeTrue();
});
it('keeps Docker run options compatible with older build-section snapshots', function () {
$application = snapshotTestApplication(['custom_docker_run_options' => '--init'])->refresh();
$previousSnapshot = $application->deploymentConfigurationSnapshot();
$buildItems = collect(data_get($previousSnapshot, 'sections.build.items'));
expect($buildItems->pluck('key'))->toContain('custom_docker_run_options');
data_set(
$previousSnapshot,
'sections.build.items',
$buildItems->map(function (array $item): array {
if ($item['key'] === 'custom_docker_run_options') {
$item['impact'] = 'build';
}
return $item;
})->all(),
);
expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $application->deploymentConfigurationSnapshot())->isChanged())->toBeFalse();
$application->update(['custom_docker_run_options' => '--rm']);
expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $application->refresh()->deploymentConfigurationSnapshot())->requiresBuild())->toBeFalse();
});
it('does not report newly tracked settings when an older snapshot omitted their default values', function () {
$application = snapshotTestApplication();
$application->settings->update(['stop_grace_period' => DEFAULT_STOP_GRACE_PERIOD_SECONDS]);
$currentSnapshot = $application->deploymentConfigurationSnapshot();
$introducedKeys = [
'is_static',
'is_spa',
'is_git_submodules_enabled',
'is_git_lfs_enabled',
'is_git_shallow_clone_enabled',
'is_env_sorting_enabled',
'is_consistent_container_name_enabled',
'is_container_label_escape_enabled',
'is_container_label_readonly_enabled',
'is_log_drain_enabled',
'is_swarm_only_worker_nodes',
'is_preserve_repository_enabled',
'stop_grace_period',
];
$previousSnapshot = $currentSnapshot;
foreach (['build', 'runtime'] as $section) {
data_set(
$previousSnapshot,
"sections.{$section}.items",
collect(data_get($previousSnapshot, "sections.{$section}.items"))
->reject(fn (array $item): bool => in_array($item['key'], $introducedKeys, true))
->values()
->all(),
);
}
expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse();
});
it('accepts the historical environment sorting default in older snapshots', function () {
$application = snapshotTestApplication();
$application->settings->update(['is_env_sorting_enabled' => true]);
$currentSnapshot = $application->deploymentConfigurationSnapshot();
$previousSnapshot = $currentSnapshot;
data_set(
$previousSnapshot,
'sections.build.items',
collect(data_get($previousSnapshot, 'sections.build.items'))
->reject(fn (array $item): bool => $item['key'] === 'is_env_sorting_enabled')
->values()
->all(),
);
expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse();
});
it('detects environment variable value changes without exposing secret values', function () {
$application = snapshotTestApplication();
EnvironmentVariable::create([
@@ -86,6 +273,7 @@ it('detects environment variable value changes without exposing secret values',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'is_shown_once' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
@@ -112,6 +300,7 @@ it('describes added environment variables as set without exposing secret values'
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'is_shown_once' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
+13
View File
@@ -0,0 +1,13 @@
<?php
it('defines OpenAPI documentation for every destinations endpoint', function () {
$controller = file_get_contents(__DIR__.'/../../app/Http/Controllers/Api/DestinationsController.php');
$model = file_get_contents(__DIR__.'/../../app/Models/StandaloneDocker.php');
expect($model)->toContain("schema: 'Destination'")
->and($controller)->toContain("operationId: 'list-destinations'")
->and($controller)->toContain("operationId: 'get-destination-by-uuid'")
->and($controller)->toContain("operationId: 'delete-destination-by-uuid'")
->and($controller)->toContain("operationId: 'list-server-destinations'")
->and($controller)->toContain("operationId: 'create-server-destination'");
});
@@ -6,6 +6,7 @@ use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
@@ -44,3 +45,59 @@ it('deletes the DigitalOcean droplet when requested', function () {
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/987');
});
it('retains the server and surfaces a DigitalOcean deletion failure', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
'message' => 'deletion failed',
], 500),
]);
$team = Team::factory()->create();
$token = CloudProviderToken::factory()->create([
'team_id' => $team->id,
'provider' => 'digitalocean',
'token' => 'test-digitalocean-token',
]);
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => 987,
]);
$server->delete();
expect(fn () => DeleteServer::run(
serverId: $server->id,
deleteFromDigitalOcean: true,
digitalOceanDropletId: 987,
cloudProviderTokenId: $token->id,
teamId: $team->id,
))->toThrow(RequestException::class, 'status code 500');
expect(Server::withTrashed()->find($server->id))->not->toBeNull();
});
it('retains the server when no DigitalOcean token can delete the droplet', function () {
Http::preventStrayRequests();
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => null,
'digitalocean_droplet_id' => 987,
]);
$server->delete();
expect(fn () => DeleteServer::run(
serverId: $server->id,
deleteFromDigitalOcean: true,
digitalOceanDropletId: 987,
teamId: $team->id,
))->toThrow(RuntimeException::class, 'No DigitalOcean token found');
expect(Server::withTrashed()->find($server->id))->not->toBeNull();
});
+76 -3
View File
@@ -10,7 +10,7 @@ use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function createDigitalOceanServerForStateTest(string $status = 'active'): Server
function createDigitalOceanServerForStateTest(string $status = 'active', array $attributes = []): Server
{
$team = Team::create([
'name' => 'Test Team',
@@ -24,13 +24,13 @@ function createDigitalOceanServerForStateTest(string $status = 'active'): Server
]);
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
return Server::factory()->create([
return Server::factory()->create(array_merge([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => 987,
'digitalocean_droplet_status' => $status,
]);
], $attributes));
}
it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () {
@@ -45,6 +45,79 @@ it('marks a DigitalOcean droplet as deleted when the provider returns 404', func
expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted');
});
it('backfills a placeholder IP from the DigitalOcean droplet state', function () {
$server = createDigitalOceanServerForStateTest('new', ['ip' => Server::PLACEHOLDER_IP]);
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
'droplet' => [
'id' => 987,
'status' => 'active',
'networks' => [
'v4' => [
['type' => 'public', 'ip_address' => '203.0.113.10'],
],
],
],
], 200),
]);
expect($server->refreshDigitalOceanState())->toBe('active');
expect($server->fresh()->ip)->toBe('203.0.113.10');
});
it('does not overwrite an administrator configured address from the DigitalOcean droplet state', function (string $configuredAddress) {
$server = createDigitalOceanServerForStateTest('active', ['ip' => $configuredAddress]);
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
'droplet' => [
'id' => 987,
'status' => 'active',
'networks' => [
'v4' => [
['type' => 'public', 'ip_address' => '203.0.113.10'],
],
],
],
], 200),
]);
$server->refreshDigitalOceanState();
expect($server->fresh()->ip)->toBe($configuredAddress);
})->with([
'public IPv4' => '198.51.100.20',
'private IPv4' => '10.10.0.12',
'IPv6' => '2001:db8::10',
'tunnel hostname' => 'server.internal.example.com',
]);
it('does not overwrite an address configured while DigitalOcean state is loading', function () {
$server = createDigitalOceanServerForStateTest('new', ['ip' => Server::PLACEHOLDER_IP]);
Http::fake(function () use ($server) {
Server::query()->findOrFail($server->id)->update([
'ip' => 'server.internal.example.com',
]);
return Http::response([
'droplet' => [
'id' => 987,
'status' => 'active',
'networks' => [
'v4' => [
['type' => 'public', 'ip_address' => '203.0.113.10'],
],
],
],
], 200);
});
expect($server->refreshDigitalOceanState())->toBe('active')
->and($server->fresh()->ip)->toBe('server.internal.example.com')
->and($server->digitalocean_droplet_status)->toBe('active');
});
it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () {
$server = createDigitalOceanServerForStateTest();
+48
View File
@@ -1,11 +1,59 @@
<?php
use App\Exceptions\RateLimitException;
use App\Services\DigitalOceanService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('maps final DigitalOcean API error responses', function (int $status, string $message) {
Http::fake([
'https://api.digitalocean.com/v2/droplets' => Http::response(['message' => $message], $status),
]);
$exception = null;
try {
(new DigitalOceanService('test-token'))->createDroplet([]);
} catch (Throwable $caught) {
$exception = $caught;
}
expect($exception)->toBeInstanceOf(Exception::class)
->and($exception)->not->toBeInstanceOf(RequestException::class)
->and($exception->getMessage())->toBe('DigitalOcean API error: '.$message)
->and($exception->getCode())->toBe($status);
Http::assertSentCount(3);
})->with([
'unauthorized' => [401, 'Unable to authenticate you'],
'unprocessable entity' => [422, 'Droplet name is invalid'],
]);
it('maps a final DigitalOcean rate-limit response with Retry-After', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets' => Http::sequence()
->push(['message' => 'Temporary provider error'], 500)
->push(['message' => 'Temporary provider error'], 500)
->push(['message' => 'Too many requests'], 429, ['Retry-After' => '45']),
]);
try {
(new DigitalOceanService('test-token'))->createDroplet([]);
} catch (RateLimitException $exception) {
expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.')
->and($exception->retryAfter)->toBe(45);
Http::assertSentCount(3);
return;
}
test()->fail('Expected a RateLimitException to be thrown.');
});
it('fetches paginated regions from DigitalOcean', function () {
Http::fake([
'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
it('normalizes plus and dotted email identity variants', function () {
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->toBe('kevinmcfadden@btinternet.com');
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com');
expect(normalize_email_identity('Ke.VinMcFadden+one@Gmail.com'))->toBe('kevinmcfadden@gmail.com');
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@googlemail.com'))->toBe('kevinmcfadden@googlemail.com');
});
it('preserves dots and plus suffixes for ordinary email providers', function () {
expect(normalize_email_identity(' John.Smith+alerts@Example.com '))->toBe('john.smith+alerts@example.com');
});
it('returns null for blank or malformed email identities', function (?string $email) {
+3 -4
View File
@@ -1,13 +1,12 @@
<?php
test('expanded navbar theme item cycles themes without a right side selector', function () {
test('navbar does not render the removed theme switcher', function () {
$navbar = file_get_contents(__DIR__.'/../../resources/views/components/navbar.blade.php');
expect(substr_count($navbar, '@click.stop="cycleTheme()"'))->toBe(2)
expect($navbar)->not->toContain('cycleTheme()')
->and($navbar)->not->toContain('aria-label="Theme switcher"')
->and($navbar)->not->toContain('ml-auto flex items-center gap-0.5')
->and($navbar)->not->toContain('@click.stop="setTheme(\'light\')"')
->and($navbar)->not->toContain('@click.stop="setTheme(\'system\')"')
->and($navbar)->not->toContain('@click.stop="setTheme(\'dark\')"')
->and($navbar)->toContain('<span class="text-left menu-item-label">Theme</span>');
->and($navbar)->not->toContain('<span class="text-left menu-item-label">Theme</span>');
});
+6 -10
View File
@@ -127,7 +127,7 @@ describe('shouldSkipDueToBackoff', function () {
});
describe('ServerConnectionCheckJob unreachable_count', function () {
it('marks Vultr servers unreachable when provider status is unavailable', function () {
it('marks servers unreachable when SSH is unavailable', function () {
Event::fake([ServerReachabilityChanged::class]);
$settings = Mockery::mock();
@@ -141,14 +141,12 @@ describe('ServerConnectionCheckJob unreachable_count', function () {
$server->shouldReceive('getAttribute')->andReturnUsing(fn (string $key) => match ($key) {
'settings' => $settings,
'unreachable_notification_sent' => false,
'vultr_instance_id' => 'instance-1',
'cloudProviderToken' => (object) ['token' => 'test-token'],
'ip' => '203.0.113.10',
'id' => 1,
'name' => 'test-server',
'unreachable_count' => 1,
default => null,
});
$server->shouldReceive('refreshVultrState')->once()->andReturn('stopped');
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
@@ -158,22 +156,20 @@ describe('ServerConnectionCheckJob unreachable_count', function () {
$job->handle();
});
it('increments unreachable_count on timeout', function () {
it('does not change reachability on a job-level timeout', function () {
Event::fake([ServerReachabilityChanged::class]);
$settings = Mockery::mock();
$settings->is_reachable = true;
$settings->shouldReceive('update')
->with(['is_reachable' => false, 'is_usable' => false])
->once();
$settings->shouldNotReceive('update');
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->shouldNotReceive('increment');
$server->id = 1;
$server->name = 'test-server';
$server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
$server->unreachable_count = 1;
$job = new ServerConnectionCheckJob($server);
$job->failed(new TimeoutExceededException);
+125
View File
@@ -0,0 +1,125 @@
<?php
use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\ScheduledJobManager;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function createServerForPlaceholderIpTest(string $ip): Server
{
$team = Team::create([
'name' => 'Test Team',
'personal_team' => false,
]);
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
return Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'ip' => $ip,
]);
}
it('detects placeholder IPs', function () {
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
foreach ([Server::PLACEHOLDER_IP, '0.0.0.0', '::'] as $ip) {
$server->update(['ip' => $ip]);
expect($server->fresh()->hasPlaceholderIp())->toBeTrue();
}
});
it('does not treat a real IP as a placeholder', function () {
$server = createServerForPlaceholderIpTest('203.0.113.5');
expect($server->hasPlaceholderIp())->toBeFalse();
});
it('backfills a placeholder IP with the real address', function () {
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
expect($server->backfillPlaceholderIp('203.0.113.5'))->toBeTrue();
expect($server->fresh()->ip)->toBe('203.0.113.5');
});
it('does not overwrite a real IP when backfilling', function () {
$server = createServerForPlaceholderIpTest('203.0.113.5');
expect($server->backfillPlaceholderIp('198.51.100.9'))->toBeFalse();
expect($server->fresh()->ip)->toBe('203.0.113.5');
});
it('ignores a missing IP when backfilling', function () {
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
expect($server->backfillPlaceholderIp(null))->toBeFalse();
expect($server->fresh()->ip)->toBe(Server::PLACEHOLDER_IP);
});
it('does not replace a placeholder with another placeholder', function (string $replacementIp) {
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
expect($server->backfillPlaceholderIp($replacementIp))->toBeFalse();
expect($server->fresh()->ip)->toBe(Server::PLACEHOLDER_IP);
})->with([
'unspecified IPv4 address' => '0.0.0.0',
'unspecified IPv6 address' => '::',
]);
it('does not overwrite an address configured after the placeholder model was loaded', function () {
$staleServer = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
$concurrentServer = Server::query()->findOrFail($staleServer->id);
$concurrentServer->update(['ip' => 'server.internal.example.com']);
expect($staleServer->backfillPlaceholderIp('203.0.113.10'))->toBeFalse()
->and($staleServer->fresh()->ip)->toBe('server.internal.example.com');
});
it('skips servers with a placeholder IP in scheduled jobs', function () {
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
expect($server->skipServer())->toBeTrue();
});
it('excludes every placeholder address from scheduled Docker cleanup', function () {
$realServer = createServerForPlaceholderIpTest('203.0.113.10');
foreach (Server::PLACEHOLDER_IPS as $placeholderIp) {
Server::factory()->create([
'team_id' => $realServer->team_id,
'private_key_id' => $realServer->private_key_id,
'ip' => $placeholderIp,
]);
}
$method = new ReflectionMethod(ScheduledJobManager::class, 'getServersForCleanupQuery');
$servers = $method->invoke(new ScheduledJobManager)->get();
expect($servers->modelKeys())->toBe([$realServer->id]);
});
it('excludes every placeholder address from orphaned preview cleanup', function () {
$realServer = createServerForPlaceholderIpTest('203.0.113.10');
$realServer->settings->update(['is_reachable' => true, 'is_usable' => true]);
foreach (Server::PLACEHOLDER_IPS as $placeholderIp) {
$server = Server::factory()->create([
'team_id' => $realServer->team_id,
'private_key_id' => $realServer->private_key_id,
'ip' => $placeholderIp,
]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
}
$method = new ReflectionMethod(CleanupOrphanedPreviewContainersJob::class, 'getServersToCheck');
$servers = $method->invoke(new CleanupOrphanedPreviewContainersJob);
expect($servers->modelKeys())->toBe([$realServer->id]);
});
@@ -0,0 +1,32 @@
<?php
it('documents both supported methods for service application actions', function () {
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
$actionPaths = [
'/services/{uuid}/applications/{app_uuid}/logs',
'/services/{uuid}/applications/{app_uuid}/start',
'/services/{uuid}/applications/{app_uuid}/restart',
'/services/{uuid}/applications/{app_uuid}/stop',
];
foreach ($actionPaths as $path) {
expect($openApi['paths'][$path])
->toHaveKeys(['get', 'post'])
->and($openApi['paths'][$path]['post']['responses'])
->toHaveKeys(['200', '400', '401', '404', '501']);
}
expect($openApi['paths'][$actionPaths[0]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('logs')
->and($openApi['paths'][$actionPaths[1]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message')
->and($openApi['paths'][$actionPaths[2]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message')
->and($openApi['paths'][$actionPaths[3]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message');
});
@@ -0,0 +1,29 @@
<?php
it('documents service database management endpoints', function () {
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
expect($openApi['paths'])
->toHaveKey('/services/{uuid}/databases')
->toHaveKey('/services/{uuid}/databases/{database_uuid}')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/logs')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/start')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/restart')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/stop')
->and($openApi['paths']['/services/{uuid}/databases']['get'])
->toBeArray()
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}'])
->toHaveKeys(['get', 'patch'])
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/logs']['get'])
->toBeArray()
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/start']['post'])
->toBeArray()
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/restart']['post'])
->toBeArray()
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/stop']['post'])
->toBeArray();
});
+28
View File
@@ -0,0 +1,28 @@
<?php
use App\Http\Controllers\Api\ServicesController;
use OpenApi\Attributes\Patch;
it('only documents fields supported by the update service endpoint', function () {
$method = new ReflectionMethod(ServicesController::class, 'update_by_uuid');
$patch = $method->getAttributes(Patch::class)[0]->newInstance();
$documentedProperties = $patch->requestBody->content[0]->schema->properties;
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
$generatedProperties = $openApi['paths']['/services/{uuid}']['patch']['requestBody']['content']['application/json']['schema']['properties'];
$unsupportedFields = [
'project_uuid',
'environment_name',
'environment_uuid',
'server_uuid',
'destination_uuid',
];
expect(array_keys($generatedProperties))->toBe(array_keys($documentedProperties))
->and(array_intersect($unsupportedFields, array_keys($documentedProperties)))->toBeEmpty();
});
+50
View File
@@ -7,6 +7,7 @@ use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
@@ -116,3 +117,52 @@ it('does not use another team Vultr token when deleting an instance', function (
expect($request->header('Authorization'))->toBe(['Bearer test-vultr-token']);
});
it('retains the server and surfaces a Vultr deletion failure', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'error' => 'deletion failed',
], 500),
]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-1',
]);
$server->delete();
expect(fn () => DeleteServer::run(
serverId: $server->id,
cloudProviderTokenId: $this->vultrToken->id,
teamId: $this->team->id,
deleteFromVultr: true,
vultrInstanceId: 'instance-1'
))->toThrow(RequestException::class, 'status code 500');
expect(Server::withTrashed()->find($server->id))->not->toBeNull();
});
it('retains the server when no Vultr token can delete the instance', function () {
Http::preventStrayRequests();
$this->vultrToken->delete();
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => null,
'vultr_instance_id' => 'instance-1',
]);
$server->delete();
expect(fn () => DeleteServer::run(
serverId: $server->id,
teamId: $this->team->id,
deleteFromVultr: true,
vultrInstanceId: 'instance-1'
))->toThrow(RuntimeException::class, 'No Vultr token found');
expect(Server::withTrashed()->find($server->id))->not->toBeNull();
});
+48
View File
@@ -1,6 +1,8 @@
<?php
use App\Exceptions\RateLimitException;
use App\Services\VultrService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
@@ -10,6 +12,52 @@ beforeEach(function () {
Http::preventStrayRequests();
});
it('maps final Vultr API error responses', function (int $status, string $message) {
Http::fake([
'https://api.vultr.com/v2/instances' => Http::response(['error' => $message], $status),
]);
$exception = null;
try {
(new VultrService('fake-token'))->createInstance([]);
} catch (Throwable $caught) {
$exception = $caught;
}
expect($exception)->toBeInstanceOf(Exception::class)
->and($exception)->not->toBeInstanceOf(RequestException::class)
->and($exception->getMessage())->toBe('Vultr API error: '.$message)
->and($exception->getCode())->toBe($status);
Http::assertSentCount(3);
})->with([
'unauthorized' => [401, 'Unauthorized'],
'unprocessable entity' => [422, 'Invalid instance parameters'],
]);
it('maps a final Vultr rate-limit response with Retry-After', function () {
Http::fake([
'https://api.vultr.com/v2/instances' => Http::sequence()
->push(['error' => 'Temporary provider error'], 500)
->push(['error' => 'Temporary provider error'], 500)
->push(['error' => 'Too many requests'], 429, ['Retry-After' => '30']),
]);
try {
(new VultrService('fake-token'))->createInstance([]);
} catch (RateLimitException $exception) {
expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.')
->and($exception->retryAfter)->toBe(30);
Http::assertSentCount(3);
return;
}
test()->fail('Expected a RateLimitException to be thrown.');
});
it('gets instances from Vultr API', function () {
Http::fake([
'https://api.vultr.com/v2/instances*' => Http::response([