mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-16 14:13:22 +00:00
Merge remote-tracking branch 'origin/next' into feat/railpack
This commit is contained in:
commit
207002dbb7
45 files changed
+1572
-272
No files matched your search
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\ActivityMonitor;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->otherTeam = Team::factory()->create();
|
||||
});
|
||||
|
||||
test('hydrateActivity blocks access to another teams activity', function () {
|
||||
$otherActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
'properties' => ['team_id' => $this->otherTeam->id],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
$component = Livewire::test(ActivityMonitor::class)
|
||||
->set('activityId', $otherActivity->id)
|
||||
->assertSet('activity', null);
|
||||
});
|
||||
|
||||
test('hydrateActivity allows access to own teams activity', function () {
|
||||
$ownActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
'properties' => ['team_id' => $this->team->id],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
$component = Livewire::test(ActivityMonitor::class)
|
||||
->set('activityId', $ownActivity->id);
|
||||
|
||||
expect($component->get('activity'))->not->toBeNull();
|
||||
expect($component->get('activity')->id)->toBe($ownActivity->id);
|
||||
});
|
||||
|
||||
test('hydrateActivity allows access to activity without team_id in properties', function () {
|
||||
$legacyActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'legacy activity',
|
||||
'properties' => [],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
$component = Livewire::test(ActivityMonitor::class)
|
||||
->set('activityId', $legacyActivity->id);
|
||||
|
||||
expect($component->get('activity'))->not->toBeNull();
|
||||
expect($component->get('activity')->id)->toBe($legacyActivity->id);
|
||||
});
|
||||
@@ -120,6 +120,82 @@ test('deleting s3 storage disables s3 on linked backups', function () {
|
||||
expect($unrelatedBackup->save_s3)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('failed method does not overwrite successful backup status', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$log = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'test-uuid-success-guard',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/test.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'message' => 'Backup completed successfully',
|
||||
'size' => 1024,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$teamProp = $reflection->getProperty('team');
|
||||
$teamProp->setValue($job, $team);
|
||||
|
||||
$logUuidProp = $reflection->getProperty('backup_log_uuid');
|
||||
$logUuidProp->setValue($job, 'test-uuid-success-guard');
|
||||
|
||||
// Simulate a post-backup failure (e.g. notification error)
|
||||
$job->failed(new Exception('Request to the Resend API failed'));
|
||||
|
||||
$log->refresh();
|
||||
expect($log->status)->toBe('success');
|
||||
expect($log->message)->toBe('Backup completed successfully');
|
||||
expect($log->size)->toBe(1024);
|
||||
});
|
||||
|
||||
test('failed method updates status when backup was not successful', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$log = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'test-uuid-pending-guard',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/test.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$teamProp = $reflection->getProperty('team');
|
||||
$teamProp->setValue($job, $team);
|
||||
|
||||
$logUuidProp = $reflection->getProperty('backup_log_uuid');
|
||||
$logUuidProp->setValue($job, 'test-uuid-pending-guard');
|
||||
|
||||
$job->failed(new Exception('Some real failure'));
|
||||
|
||||
$log->refresh();
|
||||
expect($log->status)->toBe('failed');
|
||||
expect($log->message)->toContain('Some real failure');
|
||||
});
|
||||
|
||||
test('s3 storage has scheduled backups relationship', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->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]);
|
||||
});
|
||||
@@ -53,7 +54,7 @@ test('returns 404 when application uuid belongs to another team', function () {
|
||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
||||
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
|
||||
@@ -78,3 +79,45 @@ test('returns 404 for nonexistent application uuid', function () {
|
||||
$response->assertNotFound();
|
||||
$response->assertJson(['message' => 'Application not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when server uuid belongs to another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
||||
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
->getJson("/api/v1/servers/{$otherServer->uuid}/domains");
|
||||
|
||||
$response->assertNotFound();
|
||||
$response->assertJson(['message' => 'Server not found.']);
|
||||
});
|
||||
|
||||
test('only returns domains for applications on the specified server', function () {
|
||||
$application = Application::factory()->create([
|
||||
'fqdn' => 'https://app-on-server.example.com',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$otherServer = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
|
||||
|
||||
$applicationOnOtherServer = Application::factory()->create([
|
||||
'fqdn' => 'https://app-on-other-server.example.com',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $otherDestination->id,
|
||||
'destination_type' => $otherDestination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
|
||||
|
||||
$response->assertOk();
|
||||
$responseContent = $response->json();
|
||||
$allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray();
|
||||
expect($allDomains)->toContain('app-on-server.example.com');
|
||||
expect($allDomains)->not->toContain('app-on-other-server.example.com');
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team);
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulate the preview .env generation logic from
|
||||
* ApplicationDeploymentJob::generate_runtime_environment_variables()
|
||||
* including the production fallback fix.
|
||||
*/
|
||||
function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection
|
||||
{
|
||||
$sorted_environment_variables = $application->environment_variables->sortBy('id');
|
||||
$sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id');
|
||||
|
||||
$envs = collect([]);
|
||||
|
||||
// Preview vars
|
||||
$runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime);
|
||||
foreach ($runtime_environment_variables_preview as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
|
||||
// Fallback: production vars not overridden by preview,
|
||||
// only when preview vars are configured
|
||||
if ($runtime_environment_variables_preview->isNotEmpty()) {
|
||||
$previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
|
||||
$fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
|
||||
return $env->is_runtime && ! in_array($env->key, $previewKeys);
|
||||
});
|
||||
foreach ($fallback_production_vars as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
}
|
||||
|
||||
return $envs;
|
||||
}
|
||||
|
||||
test('production vars fall back when preview vars exist but do not cover all keys', function () {
|
||||
// Create two production vars (booted hook auto-creates preview copies)
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'secret123',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'APP_KEY',
|
||||
'value' => 'app_key_value',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains
|
||||
$this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete();
|
||||
$this->application->refresh();
|
||||
|
||||
// Preview has APP_KEY but not DB_PASSWORD
|
||||
expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1);
|
||||
expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0);
|
||||
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
$envString = $envs->implode("\n");
|
||||
// DB_PASSWORD should fall back from production
|
||||
expect($envString)->toContain('DB_PASSWORD=');
|
||||
// APP_KEY should use the preview value
|
||||
expect($envString)->toContain('APP_KEY=');
|
||||
});
|
||||
|
||||
test('no fallback when no preview vars are configured at all', function () {
|
||||
// Create a production-only var (booted hook auto-creates preview copy)
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'secret123',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Delete ALL preview copies — simulates no preview config
|
||||
$this->application->environment_variables_preview()->delete();
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->environment_variables_preview()->count())->toBe(0);
|
||||
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
$envString = $envs->implode("\n");
|
||||
// Should NOT fall back to production when no preview vars exist
|
||||
expect($envString)->not->toContain('DB_PASSWORD=');
|
||||
});
|
||||
|
||||
test('preview var overrides production var when both exist', function () {
|
||||
// Create production var (auto-creates preview copy)
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'prod_password',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Update the auto-created preview copy with a different value
|
||||
$this->application->environment_variables_preview()
|
||||
->where('key', 'DB_PASSWORD')
|
||||
->update(['value' => encrypt('preview_password')]);
|
||||
|
||||
$this->application->refresh();
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
// Should contain preview value only, not production
|
||||
$envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD='));
|
||||
expect($envEntries)->toHaveCount(1);
|
||||
expect($envEntries->first())->toContain('preview_password');
|
||||
});
|
||||
|
||||
test('preview-only var works without production counterpart', function () {
|
||||
// Create a preview-only var directly (no production counterpart)
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'PREVIEW_ONLY_VAR',
|
||||
'value' => 'preview_value',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$this->application->refresh();
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
$envString = $envs->implode("\n");
|
||||
expect($envString)->toContain('PREVIEW_ONLY_VAR=');
|
||||
});
|
||||
|
||||
test('buildtime-only production vars are not included in preview fallback', function () {
|
||||
// Create a runtime preview var so fallback is active
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'SOME_PREVIEW_VAR',
|
||||
'value' => 'preview_value',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Create a buildtime-only production var
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'BUILD_SECRET',
|
||||
'value' => 'build_only',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Delete the auto-created preview copy of BUILD_SECRET
|
||||
$this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete();
|
||||
$this->application->refresh();
|
||||
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
$envString = $envs->implode("\n");
|
||||
expect($envString)->not->toContain('BUILD_SECRET');
|
||||
expect($envString)->toContain('SOME_PREVIEW_VAR=');
|
||||
});
|
||||
|
||||
test('preview env var inherits is_runtime and is_buildtime from production var', function () {
|
||||
// Create production var WITH explicit flags
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'secret123',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
|
||||
->where('is_preview', true)
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
|
||||
expect($preview)->not->toBeNull();
|
||||
expect($preview->is_runtime)->toBeTrue();
|
||||
expect($preview->is_buildtime)->toBeTrue();
|
||||
});
|
||||
|
||||
test('preview env var gets correct defaults when production var created without explicit flags', function () {
|
||||
// Simulate code paths (docker-compose parser, dev view bulk submit) that create
|
||||
// env vars without explicitly setting is_runtime/is_buildtime
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'secret123',
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
|
||||
->where('is_preview', true)
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
|
||||
expect($preview)->not->toBeNull();
|
||||
expect($preview->is_runtime)->toBeTrue();
|
||||
expect($preview->is_buildtime)->toBeTrue();
|
||||
});
|
||||
@@ -50,6 +50,93 @@ describe('customer.subscription.created does not fall through to updated', funct
|
||||
// Critical: stripe_invoice_paid must remain false — payment not yet confirmed
|
||||
expect($subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
});
|
||||
|
||||
test('created event updates existing subscription instead of duplicating', function () {
|
||||
Queue::fake();
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_old',
|
||||
'stripe_customer_id' => 'cus_old',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_new_123',
|
||||
'id' => 'sub_new_123',
|
||||
'metadata' => [
|
||||
'team_id' => $this->team->id,
|
||||
'user_id' => $this->user->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
|
||||
$subscription = Subscription::where('team_id', $this->team->id)->first();
|
||||
expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
|
||||
expect($subscription->stripe_customer_id)->toBe('cus_new_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkout.session.completed', function () {
|
||||
test('creates subscription for new team', function () {
|
||||
Queue::fake();
|
||||
|
||||
$event = [
|
||||
'type' => 'checkout.session.completed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'client_reference_id' => $this->user->id.':'.$this->team->id,
|
||||
'subscription' => 'sub_checkout_123',
|
||||
'customer' => 'cus_checkout_123',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
$subscription = Subscription::where('team_id', $this->team->id)->first();
|
||||
expect($subscription)->not->toBeNull();
|
||||
expect($subscription->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('updates existing subscription instead of duplicating', function () {
|
||||
Queue::fake();
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_old',
|
||||
'stripe_customer_id' => 'cus_old',
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'type' => 'checkout.session.completed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'client_reference_id' => $this->user->id.':'.$this->team->id,
|
||||
'subscription' => 'sub_checkout_new',
|
||||
'customer' => 'cus_checkout_new',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
|
||||
$subscription = Subscription::where('team_id', $this->team->id)->first();
|
||||
expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new');
|
||||
expect($subscription->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', true);
|
||||
});
|
||||
|
||||
it('returns server limit when team is passed directly without session', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$limit = Team::serverLimit($team);
|
||||
|
||||
// self_hosted returns 999999999999
|
||||
expect($limit)->toBe(999999999999);
|
||||
});
|
||||
|
||||
it('returns 0 when no team is provided and no session exists', function () {
|
||||
$limit = Team::serverLimit();
|
||||
|
||||
expect($limit)->toBe(0);
|
||||
});
|
||||
|
||||
it('returns true for serverLimitReached when no team and no session', function () {
|
||||
$result = Team::serverLimitReached();
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns false for serverLimitReached when team has servers under limit', function () {
|
||||
$team = Team::factory()->create();
|
||||
Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$result = Team::serverLimitReached($team);
|
||||
|
||||
// self_hosted has very high limit, 1 server is well under
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true for serverLimitReached when team has servers at limit', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$team = Team::factory()->create(['custom_server_limit' => 1]);
|
||||
Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$result = Team::serverLimitReached($team);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
@@ -10,20 +10,26 @@ beforeEach(function () {
|
||||
Cache::spy();
|
||||
});
|
||||
|
||||
function mockServerWithDbConfig(?string $savedConfig): object
|
||||
function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object
|
||||
{
|
||||
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
|
||||
$proxyAttributes->shouldReceive('get')
|
||||
->with('last_saved_proxy_configuration')
|
||||
->andReturn($savedConfig);
|
||||
|
||||
$proxyPath = match ($proxyType) {
|
||||
'CADDY' => '/data/coolify/proxy/caddy',
|
||||
'NGINX' => '/data/coolify/proxy/nginx',
|
||||
default => '/data/coolify/proxy/',
|
||||
};
|
||||
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldIgnoreMissing();
|
||||
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
|
||||
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
|
||||
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||
$server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy');
|
||||
$server->shouldReceive('proxyType')->andReturn($proxyType);
|
||||
$server->shouldReceive('proxyPath')->andReturn($proxyPath);
|
||||
|
||||
return $server;
|
||||
}
|
||||
@@ -107,3 +113,61 @@ it('does not read from disk when DB config exists', function () {
|
||||
|
||||
expect($result)->toBe($savedConfig);
|
||||
});
|
||||
|
||||
it('rejects stored Traefik config when proxy type is CADDY', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
|
||||
$server = mockServerWithDbConfig($traefikConfig, 'CADDY');
|
||||
|
||||
// Config type mismatch should trigger regeneration, which will try
|
||||
// backfillFromDisk (instant_remote_process) then generateDefault.
|
||||
// Both will fail in test env, but the warning log proves mismatch was detected.
|
||||
try {
|
||||
GetProxyConfiguration::run($server);
|
||||
} catch (\Throwable $e) {
|
||||
// Expected — regeneration requires SSH/full server setup
|
||||
}
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
|
||||
$server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK');
|
||||
|
||||
try {
|
||||
GetProxyConfiguration::run($server);
|
||||
} catch (\Throwable $e) {
|
||||
// Expected — regeneration requires SSH/full server setup
|
||||
}
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('accepts stored Caddy config when proxy type is CADDY', function () {
|
||||
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
|
||||
$server = mockServerWithDbConfig($caddyConfig, 'CADDY');
|
||||
|
||||
$result = GetProxyConfiguration::run($server);
|
||||
|
||||
expect($result)->toBe($caddyConfig);
|
||||
});
|
||||
|
||||
it('accepts stored config when YAML parsing fails', function () {
|
||||
$invalidYaml = "this: is: not: [valid yaml: {{{}}}";
|
||||
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
|
||||
|
||||
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
|
||||
$result = GetProxyConfiguration::run($server);
|
||||
|
||||
expect($result)->toBe($invalidYaml);
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* Unit tests to verify that Docker Compose environment variables
|
||||
* do not overwrite user-saved values on redeploy.
|
||||
*
|
||||
* Regression test for GitHub issue #8885.
|
||||
* Regression test for GitHub issues #8885 and #9136.
|
||||
*/
|
||||
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
@@ -14,8 +14,8 @@ it('uses firstOrCreate for simple variable references in serviceParser to preser
|
||||
// This is the key === parsedValue branch
|
||||
expect($parsersFile)->toContain(
|
||||
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
|
||||
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
|
||||
' $envVar = $resource->environment_variables()->firstOrCreate('
|
||||
" // Ensure the variable exists in DB for .env generation and UI display\n".
|
||||
' $resource->environment_variables()->firstOrCreate('
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,15 +46,15 @@ it('uses firstOrCreate for simple variable refs without default in serviceParser
|
||||
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
|
||||
});
|
||||
|
||||
it('populates environment array with saved DB value instead of raw compose variable', function () {
|
||||
it('does not replace self-referencing variable values in the environment array', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// After firstOrCreate, the environment should be populated with the DB value ($envVar->value)
|
||||
// not the raw compose variable reference (e.g., ${DATABASE_URL})
|
||||
// This pattern should appear in both parsers for all variable reference types
|
||||
expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value');
|
||||
expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;');
|
||||
expect($parsersFile)->toContain('$environment[$content] = $envVar->value;');
|
||||
// Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR}
|
||||
// reference replaced with the DB value in the compose environment section.
|
||||
// Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time.
|
||||
// This prevents stale values when users update env vars without re-parsing compose.
|
||||
expect($parsersFile)->toContain('Keep the ${VAR} reference in compose');
|
||||
expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;');
|
||||
});
|
||||
|
||||
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
|
||||
|
||||
@@ -21,6 +21,8 @@ it('accepts valid RFC 1123 hostnames', function (string $hostname) {
|
||||
'subdomain' => 'web.app.example.com',
|
||||
'max label length' => str_repeat('a', 63),
|
||||
'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59),
|
||||
'uppercase hostname' => 'MyServer',
|
||||
'mixed case fqdn' => 'MyServer.Example.COM',
|
||||
]);
|
||||
|
||||
it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) {
|
||||
@@ -36,8 +38,7 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
|
||||
expect($failCalled)->toBeTrue();
|
||||
expect($errorMessage)->toContain($expectedError);
|
||||
})->with([
|
||||
'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'],
|
||||
'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'],
|
||||
'starts with dot' => ['.myserver', 'cannot start or end with a dot'],
|
||||
@@ -46,9 +47,9 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
|
||||
'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'],
|
||||
'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'],
|
||||
'empty label' => ['my..server', 'consecutive dots'],
|
||||
'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
]);
|
||||
|
||||
it('accepts empty hostname', function () {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
it('accepts valid names with common characters', function (string $name) {
|
||||
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
|
||||
})->with([
|
||||
'simple name' => 'My Server',
|
||||
'name with hyphen' => 'my-server',
|
||||
'name with underscore' => 'my_server',
|
||||
'name with dot' => 'my.server',
|
||||
'name with slash' => 'my/server',
|
||||
'name with at sign' => 'user@host',
|
||||
'name with ampersand' => 'Tom & Jerry',
|
||||
'name with parentheses' => 'My Server (Production)',
|
||||
'name with hash' => 'Server #1',
|
||||
'name with comma' => 'Server, v2',
|
||||
'name with colon' => 'Server: Production',
|
||||
'name with plus' => 'C++ App',
|
||||
'unicode name' => 'Ünïcödé Sërvér',
|
||||
'unicode chinese' => '我的服务器',
|
||||
'numeric name' => '12345',
|
||||
'complex name' => 'App #3 (staging): v2.1+hotfix',
|
||||
]);
|
||||
|
||||
it('rejects names with dangerous characters', function (string $name) {
|
||||
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0);
|
||||
})->with([
|
||||
'semicolon' => 'my;server',
|
||||
'pipe' => 'my|server',
|
||||
'dollar sign' => 'my$server',
|
||||
'backtick' => 'my`server',
|
||||
'backslash' => 'my\\server',
|
||||
'less than' => 'my<server',
|
||||
'greater than' => 'my>server',
|
||||
'curly braces' => 'my{server}',
|
||||
'square brackets' => 'my[server]',
|
||||
'tilde' => 'my~server',
|
||||
'caret' => 'my^server',
|
||||
'question mark' => 'my?server',
|
||||
'percent' => 'my%server',
|
||||
'double quote' => 'my"server',
|
||||
'exclamation' => 'my!server',
|
||||
'asterisk' => 'my*server',
|
||||
]);
|
||||
|
||||
it('generates nameRules with correct defaults', function () {
|
||||
$rules = ValidationPatterns::nameRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('min:3')
|
||||
->toContain('max:255')
|
||||
->toContain('regex:'.ValidationPatterns::NAME_PATTERN);
|
||||
});
|
||||
|
||||
it('generates nullable nameRules when not required', function () {
|
||||
$rules = ValidationPatterns::nameRules(required: false);
|
||||
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
|
||||
it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) {
|
||||
$name = generate_application_name($repo, $branch, 'testcuid');
|
||||
|
||||
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
|
||||
})->with([
|
||||
'normal repo' => ['owner/my-app', 'main'],
|
||||
'repo with dots' => ['repo.with.dots', 'feat/branch'],
|
||||
'repo with plus' => ['C++ App', 'main'],
|
||||
'branch with parens' => ['my-app', 'fix(auth)-login'],
|
||||
'repo with exclamation' => ['my-app!', 'main'],
|
||||
'repo with brackets' => ['app[test]', 'develop'],
|
||||
]);
|
||||
|
||||
it('falls back to random name when repo produces empty name', function () {
|
||||
$name = generate_application_name('!!!', 'main', 'testcuid');
|
||||
|
||||
expect(mb_strlen($name))->toBeGreaterThanOrEqual(3)
|
||||
->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
|
||||
});
|
||||
Reference in new issue
Block a user