mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 16:14:42 +00:00
Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react
This commit is contained in:
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", [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.']);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user