mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 08:17:02 +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,
|
||||
|
||||
Reference in new issue
Block a user