mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 22:15:35 +00:00
Merge remote-tracking branch 'origin/next' into 8649-parseaddr-bug
This commit is contained in:
commit
2ad7df2dea
63 files changed
+4608
-929
No files matched your search
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
|
||||
describe('Application Rollback', function () {
|
||||
beforeEach(function () {
|
||||
$this->application = new Application;
|
||||
$this->application->forceFill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = false;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
$this->application->setRelation('settings', $settings);
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () {
|
||||
$rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
commit: $rollbackCommit
|
||||
);
|
||||
|
||||
expect($result)->toContain($rollbackCommit);
|
||||
});
|
||||
|
||||
test('setGitImportSettings with shallow clone fetches specific commit', function () {
|
||||
$this->application->settings->is_git_shallow_clone_enabled = true;
|
||||
|
||||
$rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
commit: $rollbackCommit
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('git fetch --depth=1 origin')
|
||||
->toContain($rollbackCommit);
|
||||
});
|
||||
|
||||
test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () {
|
||||
$this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
);
|
||||
|
||||
expect($result)->toContain('def789abc012def789abc012def789abc012def7');
|
||||
});
|
||||
|
||||
test('setGitImportSettings escapes shell metacharacters in commit parameter', function () {
|
||||
$maliciousCommit = 'abc123; rm -rf /';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
commit: $maliciousCommit
|
||||
);
|
||||
|
||||
// escapeshellarg wraps the value in single quotes, neutralizing metacharacters
|
||||
expect($result)
|
||||
->toContain("checkout 'abc123; rm -rf /'")
|
||||
->not->toContain('checkout abc123; rm -rf /');
|
||||
});
|
||||
|
||||
test('setGitImportSettings does not append checkout when commit is HEAD', function () {
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
);
|
||||
|
||||
expect($result)->not->toContain('advice.detachedHead=false checkout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
$this->application = Application::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('environment variable can be created with comment', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'This is a test environment variable',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe('This is a test environment variable');
|
||||
expect($env->key)->toBe('TEST_VAR');
|
||||
expect($env->value)->toBe('test_value');
|
||||
});
|
||||
|
||||
test('environment variable comment is optional', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBeNull();
|
||||
expect($env->key)->toBe('TEST_VAR');
|
||||
});
|
||||
|
||||
test('environment variable comment can be updated', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Initial comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->comment = 'Updated comment';
|
||||
$env->save();
|
||||
|
||||
$env->refresh();
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
});
|
||||
|
||||
test('environment variable comment is preserved when updating value', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'initial_value',
|
||||
'comment' => 'Important variable for testing',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->value = 'new_value';
|
||||
$env->save();
|
||||
|
||||
$env->refresh();
|
||||
expect($env->value)->toBe('new_value');
|
||||
expect($env->comment)->toBe('Important variable for testing');
|
||||
});
|
||||
|
||||
test('environment variable comment is copied to preview environment', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Test comment',
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// The model's created() event listener automatically creates a preview version
|
||||
$previewEnv = EnvironmentVariable::where('key', 'TEST_VAR')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->where('is_preview', true)
|
||||
->first();
|
||||
|
||||
expect($previewEnv)->not->toBeNull();
|
||||
expect($previewEnv->comment)->toBe('Test comment');
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves values without inline comments', function () {
|
||||
$input = "KEY1=value1\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('developer view format does not break with comment-like values', function () {
|
||||
// Values that contain # but shouldn't be treated as comments when quoted
|
||||
$env1 = EnvironmentVariable::create([
|
||||
'key' => 'HASH_VAR',
|
||||
'value' => 'value_with_#_in_it',
|
||||
'comment' => 'Contains hash symbol',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env1->value)->toBe('value_with_#_in_it');
|
||||
expect($env1->comment)->toBe('Contains hash symbol');
|
||||
});
|
||||
|
||||
test('environment variable comment can store up to 256 characters', function () {
|
||||
$comment = str_repeat('a', 256);
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => $comment,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe($comment);
|
||||
expect(strlen($env->comment))->toBe(256);
|
||||
});
|
||||
|
||||
test('environment variable comment cannot exceed 256 characters via Livewire', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$longComment = str_repeat('a', 257);
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
|
||||
->set('comment', $longComment)
|
||||
->call('submit')
|
||||
->assertHasErrors(['comment' => 'max']);
|
||||
});
|
||||
|
||||
test('bulk update preserves existing comments when no inline comment provided', function () {
|
||||
// Create existing variable with a manually-entered comment
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'DATABASE_URL',
|
||||
'value' => 'postgres://old-host',
|
||||
'comment' => 'Production database',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// User switches to Developer view and pastes new value without inline comment
|
||||
$bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh the environment variable
|
||||
$env->refresh();
|
||||
|
||||
// The value should be updated
|
||||
expect($env->value)->toBe('postgres://new-host');
|
||||
|
||||
// The manually-entered comment should be PRESERVED
|
||||
expect($env->comment)->toBe('Production database');
|
||||
});
|
||||
|
||||
test('bulk update overwrites existing comments when inline comment provided', function () {
|
||||
// Create existing variable with a comment
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'old-key',
|
||||
'comment' => 'Old comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// User pastes new value WITH inline comment
|
||||
$bulkContent = 'API_KEY=new-key #Updated production key';
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh the environment variable
|
||||
$env->refresh();
|
||||
|
||||
// The value should be updated
|
||||
expect($env->value)->toBe('new-key');
|
||||
|
||||
// The comment should be OVERWRITTEN with the inline comment
|
||||
expect($env->comment)->toBe('Updated production key');
|
||||
});
|
||||
|
||||
test('bulk update handles mixed inline and stored comments correctly', function () {
|
||||
// Create two variables with comments
|
||||
$env1 = EnvironmentVariable::create([
|
||||
'key' => 'VAR_WITH_COMMENT',
|
||||
'value' => 'value1',
|
||||
'comment' => 'Existing comment 1',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env2 = EnvironmentVariable::create([
|
||||
'key' => 'VAR_WITHOUT_COMMENT',
|
||||
'value' => 'value2',
|
||||
'comment' => 'Existing comment 2',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Bulk paste: one with inline comment, one without
|
||||
$bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh both variables
|
||||
$env1->refresh();
|
||||
$env2->refresh();
|
||||
|
||||
// First variable: comment should be overwritten with inline comment
|
||||
expect($env1->value)->toBe('new_value1');
|
||||
expect($env1->comment)->toBe('New inline comment');
|
||||
|
||||
// Second variable: comment should be preserved
|
||||
expect($env2->value)->toBe('new_value2');
|
||||
expect($env2->comment)->toBe('Existing comment 2');
|
||||
});
|
||||
|
||||
test('bulk update creates new variables with inline comments', function () {
|
||||
// Bulk paste creates new variables, some with inline comments
|
||||
$bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Check that variables were created with correct comments
|
||||
$var1 = EnvironmentVariable::where('key', 'NEW_VAR1')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
$var2 = EnvironmentVariable::where('key', 'NEW_VAR2')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
$var3 = EnvironmentVariable::where('key', 'NEW_VAR3')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
|
||||
expect($var1->value)->toBe('value1');
|
||||
expect($var1->comment)->toBe('Comment for var1');
|
||||
|
||||
expect($var2->value)->toBe('value2');
|
||||
expect($var2->comment)->toBeNull();
|
||||
|
||||
expect($var3->value)->toBe('value3');
|
||||
expect($var3->comment)->toBe('Comment for var3');
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
$this->application = Application::factory()->create();
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('all fillable fields can be mass assigned', function () {
|
||||
$data = [
|
||||
'key' => 'TEST_KEY',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Test comment',
|
||||
'is_literal' => true,
|
||||
'is_multiline' => true,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'is_shown_once' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
];
|
||||
|
||||
$env = EnvironmentVariable::create($data);
|
||||
|
||||
expect($env->key)->toBe('TEST_KEY');
|
||||
expect($env->value)->toBe('test_value');
|
||||
expect($env->comment)->toBe('Test comment');
|
||||
expect($env->is_literal)->toBeTrue();
|
||||
expect($env->is_multiline)->toBeTrue();
|
||||
expect($env->is_preview)->toBeFalse();
|
||||
expect($env->is_runtime)->toBeTrue();
|
||||
expect($env->is_buildtime)->toBeFalse();
|
||||
expect($env->is_shown_once)->toBeFalse();
|
||||
expect($env->resourceable_type)->toBe(Application::class);
|
||||
expect($env->resourceable_id)->toBe($this->application->id);
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with null', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => null,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBeNull();
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with empty string', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => '',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe('');
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with long text', function () {
|
||||
$comment = str_repeat('This is a long comment. ', 10);
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => $comment,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe($comment);
|
||||
expect(strlen($env->comment))->toBe(strlen($comment));
|
||||
});
|
||||
|
||||
test('all boolean fields default correctly when not provided', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Boolean fields can be null or false depending on database defaults
|
||||
expect($env->is_multiline)->toBeIn([false, null]);
|
||||
expect($env->is_preview)->toBeIn([false, null]);
|
||||
expect($env->is_runtime)->toBeIn([false, null]);
|
||||
expect($env->is_buildtime)->toBeIn([false, null]);
|
||||
expect($env->is_shown_once)->toBeIn([false, null]);
|
||||
});
|
||||
|
||||
test('value field is properly encrypted when mass assigned', function () {
|
||||
$plainValue = 'secret_value_123';
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'SECRET_KEY',
|
||||
'value' => $plainValue,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Value should be decrypted when accessed via model
|
||||
expect($env->value)->toBe($plainValue);
|
||||
|
||||
// Verify it's actually encrypted in the database
|
||||
$rawValue = \DB::table('environment_variables')
|
||||
->where('id', $env->id)
|
||||
->value('value');
|
||||
|
||||
expect($rawValue)->not->toBe($plainValue);
|
||||
expect($rawValue)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('key field is trimmed and spaces replaced with underscores', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => ' TEST KEY WITH SPACES ',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
|
||||
});
|
||||
|
||||
test('version field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'version' => '1.2.3',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// The booted() method sets version automatically, so it will be the current version
|
||||
expect($env->version)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('mass assignment works with update method', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'initial_value',
|
||||
'comment' => 'Initial comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->update([
|
||||
'value' => 'updated_value',
|
||||
'comment' => 'Updated comment',
|
||||
'is_literal' => true,
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
|
||||
expect($env->value)->toBe('updated_value');
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
expect($env->is_literal)->toBeTrue();
|
||||
});
|
||||
|
||||
test('protected attributes cannot be mass assigned', function () {
|
||||
$customDate = '2020-01-01 00:00:00';
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'id' => 999999,
|
||||
'uuid' => 'custom-uuid',
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
'created_at' => $customDate,
|
||||
'updated_at' => $customDate,
|
||||
]);
|
||||
|
||||
// id should be auto-generated, not 999999
|
||||
expect($env->id)->not->toBe(999999);
|
||||
|
||||
// uuid should be auto-generated, not 'custom-uuid'
|
||||
expect($env->uuid)->not->toBe('custom-uuid');
|
||||
|
||||
// Timestamps should be current, not 2020
|
||||
expect($env->created_at->year)->toBe(now()->year);
|
||||
});
|
||||
|
||||
test('order field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'order' => 5,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->order)->toBe(5);
|
||||
});
|
||||
|
||||
test('is_shared field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'is_shared' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Note: is_shared is also computed via accessor, but can be mass assigned
|
||||
expect($env->is_shared)->not->toBeNull();
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
@@ -15,6 +16,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// ApiAllowed middleware requires InstanceSettings with id=0
|
||||
InstanceSettings::create(['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']);
|
||||
@@ -25,12 +29,14 @@ beforeEach(function () {
|
||||
$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]);
|
||||
// Server::booted() auto-creates a StandaloneDocker, reuse it
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
// Project::booted() auto-creates a 'production' Environment, reuse it
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->environment = $this->project->environments()->first();
|
||||
});
|
||||
|
||||
function authHeaders($bearerToken): array
|
||||
function scheduledTaskAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
@@ -46,7 +52,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -66,7 +72,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'name' => 'Test Task',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -75,7 +81,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
});
|
||||
|
||||
test('returns 404 for unknown application uuid', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
|
||||
|
||||
$response->assertStatus(404);
|
||||
@@ -90,7 +96,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Backup',
|
||||
'command' => 'php artisan backup',
|
||||
@@ -116,7 +122,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'command' => 'echo test',
|
||||
'frequency' => '* * * * *',
|
||||
@@ -132,7 +138,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
@@ -150,7 +156,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
@@ -168,7 +174,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
@@ -199,7 +205,7 @@ describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', functi
|
||||
'name' => 'Old Name',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
@@ -215,7 +221,7 @@ describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', functi
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
|
||||
'name' => 'Test',
|
||||
]);
|
||||
@@ -237,7 +243,7 @@ describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', funct
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -253,7 +259,7 @@ describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', funct
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
|
||||
|
||||
$response->assertStatus(404);
|
||||
@@ -279,7 +285,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions
|
||||
'message' => 'OK',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -294,7 +300,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
|
||||
|
||||
$response->assertStatus(404);
|
||||
@@ -316,7 +322,7 @@ describe('Service scheduled tasks API', function () {
|
||||
'name' => 'Service Task',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -332,7 +338,7 @@ describe('Service scheduled tasks API', function () {
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
|
||||
'name' => 'Service Backup',
|
||||
'command' => 'pg_dump',
|
||||
@@ -356,7 +362,7 @@ describe('Service scheduled tasks API', function () {
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Mockery;
|
||||
|
||||
/**
|
||||
* Unit test to verify docker_compose_raw is properly synced to the Livewire component
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
// Clean up Mockery after each test
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Mockery;
|
||||
|
||||
/**
|
||||
* Unit tests to verify that containers without health checks are not
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
|
||||
test('fillable array contains all fields used in mass assignment across codebase', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$fillable = $model->getFillable();
|
||||
|
||||
// Core identification
|
||||
expect($fillable)->toContain('key')
|
||||
->toContain('value')
|
||||
->toContain('comment');
|
||||
|
||||
// Polymorphic relationship
|
||||
expect($fillable)->toContain('resourceable_type')
|
||||
->toContain('resourceable_id');
|
||||
|
||||
// Boolean flags — all used in create/firstOrCreate/updateOrCreate calls
|
||||
expect($fillable)->toContain('is_preview')
|
||||
->toContain('is_multiline')
|
||||
->toContain('is_literal')
|
||||
->toContain('is_runtime')
|
||||
->toContain('is_buildtime')
|
||||
->toContain('is_shown_once')
|
||||
->toContain('is_shared')
|
||||
->toContain('is_required');
|
||||
|
||||
// Metadata
|
||||
expect($fillable)->toContain('version')
|
||||
->toContain('order');
|
||||
});
|
||||
|
||||
test('is_required can be mass assigned', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(['is_required' => true]);
|
||||
|
||||
expect($model->is_required)->toBeTrue();
|
||||
});
|
||||
|
||||
test('all boolean flags can be mass assigned', function () {
|
||||
$booleanFlags = [
|
||||
'is_preview',
|
||||
'is_multiline',
|
||||
'is_literal',
|
||||
'is_runtime',
|
||||
'is_buildtime',
|
||||
'is_shown_once',
|
||||
'is_required',
|
||||
];
|
||||
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(array_fill_keys($booleanFlags, true));
|
||||
|
||||
foreach ($booleanFlags as $flag) {
|
||||
expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true");
|
||||
}
|
||||
|
||||
// is_shared has a computed getter derived from the value field,
|
||||
// so verify it's fillable via the underlying attributes instead
|
||||
$model2 = new EnvironmentVariable;
|
||||
$model2->fill(['is_shared' => true]);
|
||||
expect($model2->getAttributes())->toHaveKey('is_shared');
|
||||
});
|
||||
|
||||
test('non-fillable fields are rejected by mass assignment', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']);
|
||||
|
||||
expect($model->id)->toBeNull()
|
||||
->and($model->uuid)->toBeNull()
|
||||
->and($model->created_at)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
use App\Models\EnvironmentVariable;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_FQDN_DB');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_URL variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_URL_API');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_NAME variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_NAME');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('regular variables are not magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('DATABASE_URL');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeFalse();
|
||||
expect($component->isDisabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('locked variables are not magic variables unless they start with SERVICE_', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SECRET_KEY');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(true);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeFalse();
|
||||
expect($component->isLocked)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN with port suffix is identified as magic variable', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_FQDN_DB_5432');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_URL with port suffix is identified as magic variable', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_URL_API_8080');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertNotNull;
|
||||
use function PHPUnit\Framework\assertNull;
|
||||
|
||||
// ─── Malformed Variables ───────────────────────────────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent handles empty variable name', function () {
|
||||
$result = extractBalancedBraceContent('${}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles empty variable name with default', function () {
|
||||
$split = splitOnOperatorOutsideNested(':-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles double opening brace', function () {
|
||||
$result = extractBalancedBraceContent('${{VAR}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('{VAR}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for empty string', function () {
|
||||
$result = extractBalancedBraceContent('', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just dollar sign', function () {
|
||||
$result = extractBalancedBraceContent('$', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just opening brace', function () {
|
||||
$result = extractBalancedBraceContent('{', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just closing brace', function () {
|
||||
$result = extractBalancedBraceContent('}', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles extra closing brace', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for unclosed with no content', function () {
|
||||
$result = extractBalancedBraceContent('${', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () {
|
||||
$result = extractBalancedBraceContent('${A:-${B:-${C}', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('replaceVariables handles empty braces gracefully', function () {
|
||||
$result = replaceVariables('${}');
|
||||
|
||||
expect($result->value())->toBe('');
|
||||
});
|
||||
|
||||
test('replaceVariables handles double braces gracefully', function () {
|
||||
$result = replaceVariables('${{VAR}}');
|
||||
|
||||
expect($result->value())->toBe('{VAR}');
|
||||
});
|
||||
|
||||
// ─── Edge Cases with Braces and Special Characters ─────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent finds consecutive variables', function () {
|
||||
$str = '${A}${B}';
|
||||
|
||||
$first = extractBalancedBraceContent($str, 0);
|
||||
assertNotNull($first);
|
||||
expect($first['content'])->toBe('A');
|
||||
|
||||
$second = extractBalancedBraceContent($str, $first['end'] + 1);
|
||||
assertNotNull($second);
|
||||
expect($second['content'])->toBe('B');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles URL with port in default', function () {
|
||||
$split = splitOnOperatorOutsideNested('URL:-http://host:8080/path');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('http://host:8080/path');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles equals sign in default', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('key=value&foo=bar');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles dashes in default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-value-with-dashes');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('value-with-dashes');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles question mark in default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-what?');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('what?');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles variable with digits', function () {
|
||||
$result = extractBalancedBraceContent('${VAR123}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR123');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles long variable name', function () {
|
||||
$longName = str_repeat('A', 200);
|
||||
$result = extractBalancedBraceContent('${'.$longName.'}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe($longName);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested returns null for empty string', function () {
|
||||
$split = splitOnOperatorOutsideNested('');
|
||||
|
||||
assertNull($split);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles variable name with underscores', function () {
|
||||
$split = splitOnOperatorOutsideNested('_MY_VAR_:-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('_MY_VAR_')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent with startPos beyond string length', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}', 100);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles brace in middle of text', function () {
|
||||
$result = extractBalancedBraceContent('prefix ${VAR} suffix', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR');
|
||||
});
|
||||
|
||||
// ─── Deeply Nested Defaults ────────────────────────────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent handles four levels of nesting', function () {
|
||||
$input = '${A:-${B:-${C:-${D}}}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('A:-${B:-${C:-${D}}}');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles four levels of nesting', function () {
|
||||
$content = 'A:-${B:-${C:-${D}}}';
|
||||
$split = splitOnOperatorOutsideNested($content);
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-${C:-${D}}}');
|
||||
|
||||
// Verify second level
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
assertNotNull($nested);
|
||||
$split2 = splitOnOperatorOutsideNested($nested['content']);
|
||||
assertNotNull($split2);
|
||||
expect($split2['variable'])->toBe('B')
|
||||
->and($split2['default'])->toBe('${C:-${D}}');
|
||||
});
|
||||
|
||||
test('multiple variables at same depth in default', function () {
|
||||
$input = '${A:-${B}/${C}/${D}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
assertNotNull($result);
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
assertNotNull($split);
|
||||
expect($split['default'])->toBe('${B}/${C}/${D}');
|
||||
|
||||
// Verify all three nested variables can be found
|
||||
$default = $split['default'];
|
||||
$vars = [];
|
||||
$pos = 0;
|
||||
while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
|
||||
$vars[] = $nested['content'];
|
||||
$pos = $nested['end'] + 1;
|
||||
}
|
||||
|
||||
expect($vars)->toBe(['B', 'C', 'D']);
|
||||
});
|
||||
|
||||
test('nested with mixed operators', function () {
|
||||
$input = '${A:-${B:?required}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:?required}');
|
||||
|
||||
// Inner variable uses :? operator
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
|
||||
expect($innerSplit['variable'])->toBe('B')
|
||||
->and($innerSplit['operator'])->toBe(':?')
|
||||
->and($innerSplit['default'])->toBe('required');
|
||||
});
|
||||
|
||||
test('nested variable without default as default', function () {
|
||||
$input = '${A:-${B}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${B}');
|
||||
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
|
||||
assertNull($innerSplit);
|
||||
expect($nested['content'])->toBe('B');
|
||||
});
|
||||
|
||||
// ─── Backwards Compatibility ───────────────────────────────────────────────────
|
||||
|
||||
test('replaceVariables with brace format without dollar sign', function () {
|
||||
$result = replaceVariables('{MY_VAR}');
|
||||
|
||||
expect($result->value())->toBe('MY_VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables with truncated brace format', function () {
|
||||
$result = replaceVariables('{MY_VAR');
|
||||
|
||||
expect($result->value())->toBe('MY_VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables with plain string returns unchanged', function () {
|
||||
$result = replaceVariables('plain_value');
|
||||
|
||||
expect($result->value())->toBe('plain_value');
|
||||
});
|
||||
|
||||
test('replaceVariables preserves full content for variable with default', function () {
|
||||
$result = replaceVariables('${DB_HOST:-localhost}');
|
||||
|
||||
expect($result->value())->toBe('DB_HOST:-localhost');
|
||||
});
|
||||
|
||||
test('replaceVariables preserves nested content for variable with nested default', function () {
|
||||
$result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}');
|
||||
|
||||
expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db');
|
||||
});
|
||||
|
||||
test('replaceVariables with brace format containing default falls back gracefully', function () {
|
||||
$result = replaceVariables('{VAR:-default}');
|
||||
|
||||
expect($result->value())->toBe('VAR:-default');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-val-ue');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['operator'])->toBe(':-')
|
||||
->and($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('val-ue');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:?error?');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['operator'])->toBe(':?')
|
||||
->and($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('error?');
|
||||
});
|
||||
|
||||
test('full round trip: extract, split, and resolve nested variables', function () {
|
||||
$input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}';
|
||||
|
||||
// Step 1: Extract outer content
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
|
||||
|
||||
// Step 2: Split on outer operator
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('APP_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
|
||||
|
||||
// Step 3: Find all nested variables in default
|
||||
$default = $split['default'];
|
||||
$nestedVars = [];
|
||||
$pos = 0;
|
||||
while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
$nestedVars[] = [
|
||||
'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'],
|
||||
'default' => $innerSplit !== null ? $innerSplit['default'] : null,
|
||||
];
|
||||
$pos = $nested['end'] + 1;
|
||||
}
|
||||
|
||||
expect($nestedVars)->toHaveCount(2)
|
||||
->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP')
|
||||
->and($nestedVars[0]['default'])->toBeNull()
|
||||
->and($nestedVars[1]['name'])->toBe('API_VERSION')
|
||||
->and($nestedVars[1]['default'])->toBe('2');
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
test('extracts simple environment variables from docker-compose', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('NODE_ENV')
|
||||
->and($result[0]['value'])->toBe('production')
|
||||
->and($result[0]['service_name'])->toBe('app')
|
||||
->and($result[1]['key'])->toBe('PORT')
|
||||
->and($result[1]['value'])->toBe('3000')
|
||||
->and($result[1]['service_name'])->toBe('app');
|
||||
});
|
||||
|
||||
test('extracts environment variables with inline comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- NODE_ENV=production # Production environment
|
||||
- DEBUG=false # Disable debug mode
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['comment'])->toBe('Production environment')
|
||||
->and($result[1]['comment'])->toBe('Disable debug mode');
|
||||
});
|
||||
|
||||
test('handles multiple services', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- APP_ENV=prod
|
||||
db:
|
||||
environment:
|
||||
- POSTGRES_DB=mydb
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('APP_ENV')
|
||||
->and($result[0]['service_name'])->toBe('app')
|
||||
->and($result[1]['key'])->toBe('POSTGRES_DB')
|
||||
->and($result[1]['service_name'])->toBe('db');
|
||||
});
|
||||
|
||||
test('handles associative array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('NODE_ENV')
|
||||
->and($result[0]['value'])->toBe('production')
|
||||
->and($result[1]['key'])->toBe('PORT')
|
||||
->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
|
||||
});
|
||||
|
||||
test('handles environment variables without values', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- API_KEY
|
||||
- DEBUG=false
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('API_KEY')
|
||||
->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
|
||||
->and($result[1]['key'])->toBe('DEBUG')
|
||||
->and($result[1]['value'])->toBe('false');
|
||||
});
|
||||
|
||||
test('returns empty collection for malformed YAML', function () {
|
||||
$yaml = 'invalid: yaml: content::: [[[';
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection for empty compose file', function () {
|
||||
$result = extractHardcodedEnvironmentVariables('');
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection when no services defined', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: '3.8'
|
||||
networks:
|
||||
default:
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection when service has no environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('handles mixed associative and array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
PORT: 3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
// Mixed format is invalid YAML and returns empty collection
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
test('extractYamlEnvironmentComments returns empty array for YAML without environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments extracts inline comments from map format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
FOO: bar # This is a comment
|
||||
BAZ: qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments extracts inline comments from array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- FOO=bar # This is a comment
|
||||
- BAZ=qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
COLOR: "#FF0000" # hex color code
|
||||
DB_URL: "postgres://user:pass#123@localhost" # database URL
|
||||
PLAIN: value # no quotes
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'COLOR' => 'hex color code',
|
||||
'DB_URL' => 'database URL',
|
||||
'PLAIN' => 'no quotes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
PASSWORD: 'secret#123' # my password
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => 'my password',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments skips full-line comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
# This is a full line comment
|
||||
FOO: bar # This is an inline comment
|
||||
# Another full line comment
|
||||
BAZ: qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is an inline comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles multiple services', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
WEB_PORT: 8080 # web server port
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: admin # database admin user
|
||||
POSTGRES_PASSWORD: secret
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'WEB_PORT' => 'web server port',
|
||||
'POSTGRES_USER' => 'database admin user',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles variables without values', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- DEBUG # enable debug mode
|
||||
- VERBOSE
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DEBUG' => 'enable debug mode',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles array format with colons', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- DATABASE_URL: postgres://localhost # connection string
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DATABASE_URL' => 'connection string',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
API_KEY: abc#def
|
||||
OTHER: xyz # this is a comment
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// abc#def has no space before #, so it's not treated as a comment
|
||||
expect($result)->toBe([
|
||||
'OTHER' => 'this is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles empty environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
ports:
|
||||
- "80:80"
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () {
|
||||
// Inline format like environment: { FOO: bar } is not supported for comment extraction
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment: { FOO: bar }
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// No comments extracted from inline format
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: myapp:latest
|
||||
environment:
|
||||
NODE_ENV: production # Set to development for local
|
||||
DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database
|
||||
REDIS_URL: "redis://cache:6379"
|
||||
API_SECRET: "${API_SECRET}" # From .env file
|
||||
LOG_LEVEL: debug # Options: debug, info, warn, error
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: user # Database admin username
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||
POSTGRES_DB: mydb
|
||||
|
||||
cache:
|
||||
image: redis:7
|
||||
environment:
|
||||
- REDIS_MAXMEMORY=256mb # Memory limit for cache
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'NODE_ENV' => 'Set to development for local',
|
||||
'DATABASE_URL' => 'Main database',
|
||||
'API_SECRET' => 'From .env file',
|
||||
'LOG_LEVEL' => 'Options: debug, info, warn, error',
|
||||
'POSTGRES_USER' => 'Database admin username',
|
||||
'REDIS_MAXMEMORY' => 'Memory limit for cache',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
FOO: bar # comment # with # hashes
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'comment # with # hashes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles variables with empty comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
FOO: bar #
|
||||
BAZ: qux #
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// Empty comments should not be included
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments properly exits environment block on new section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
FOO: bar # env comment
|
||||
ports:
|
||||
- "80:80" # port comment should not be captured
|
||||
volumes:
|
||||
- ./data:/data # volume comment should not be captured
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// Only environment variables should have comments extracted
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'env comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles SERVICE_ variables', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
SERVICE_FQDN_WEB: /api # Path for the web service
|
||||
SERVICE_URL_WEB: # URL will be generated
|
||||
NORMAL_VAR: value # Regular variable
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'SERVICE_FQDN_WEB' => 'Path for the web service',
|
||||
'SERVICE_URL_WEB' => 'URL will be generated',
|
||||
'NORMAL_VAR' => 'Regular variable',
|
||||
]);
|
||||
});
|
||||
@@ -5,7 +5,6 @@ use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationSetting;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
Mockery::close();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Notifications\Server\HetznerDeletionFailed;
|
||||
use Mockery;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
test('DockerCompose handles array format from parseEnvFormatToArray', function () {
|
||||
// Simulate the array format that parseEnvFormatToArray returns
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'],
|
||||
'KEY3' => ['value' => 'value3', 'comment' => null],
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY2') {
|
||||
expect($value)->toBe('value2');
|
||||
expect($comment)->toBe('This is a comment');
|
||||
} elseif ($key === 'KEY3') {
|
||||
expect($value)->toBe('value3');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles plain string format gracefully', function () {
|
||||
// Simulate a scenario where parseEnvFormatToArray might return plain strings
|
||||
// (for backward compatibility or edge cases)
|
||||
$variables = [
|
||||
'KEY1' => 'value1',
|
||||
'KEY2' => 'value2',
|
||||
'KEY3' => 'value3',
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($comment)->toBeNull();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles mixed array and string formats', function () {
|
||||
// Simulate a mixed scenario (unlikely but possible)
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1', 'comment' => 'comment1'],
|
||||
'KEY2' => 'value2', // Plain string
|
||||
'KEY3' => ['value' => 'value3', 'comment' => null],
|
||||
'KEY4' => 'value4', // Plain string
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBe('comment1');
|
||||
} elseif ($key === 'KEY2') {
|
||||
expect($value)->toBe('value2');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY3') {
|
||||
expect($value)->toBe('value3');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY4') {
|
||||
expect($value)->toBe('value4');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles empty array values gracefully', function () {
|
||||
// Simulate edge case with incomplete array structure
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1'], // Missing 'comment' key
|
||||
'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case)
|
||||
'KEY3' => [], // Empty array (edge case)
|
||||
];
|
||||
|
||||
// Test the extraction logic with improved fallback
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction doesn't crash
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY2') {
|
||||
// If 'value' is missing, fallback to empty string (not the whole array)
|
||||
expect($value)->toBe('');
|
||||
expect($comment)->toBe('comment2');
|
||||
} elseif ($key === 'KEY3') {
|
||||
// If both are missing, fallback to empty string (not empty array)
|
||||
expect($value)->toBe('');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Tests for nested environment variable parsing in Docker Compose files.
|
||||
*
|
||||
* These tests verify that the parser correctly handles nested variable substitution syntax
|
||||
* like ${API_URL:-${SERVICE_URL_YOLO}/api} where defaults can contain other variable references.
|
||||
*/
|
||||
test('nested variable syntax is parsed correctly', function () {
|
||||
// Test the exact scenario from the bug report
|
||||
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
expect($result)->not->toBeNull()
|
||||
->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split)->not->toBeNull()
|
||||
->and($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('replaceVariables correctly extracts nested variable content', function () {
|
||||
// Before the fix, this would incorrectly extract only up to the first closing brace
|
||||
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
|
||||
|
||||
// Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
|
||||
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
|
||||
->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
|
||||
});
|
||||
|
||||
test('nested defaults with path concatenation work', function () {
|
||||
$input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('REDIS_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
|
||||
});
|
||||
|
||||
test('deeply nested variables are handled', function () {
|
||||
// Three levels of nesting
|
||||
$input = '${A:-${B:-${C}}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
expect($result['content'])->toBe('A:-${B:-${C}}');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['default'])->toBe('${B:-${C}}');
|
||||
});
|
||||
|
||||
test('multiple nested variables in default value', function () {
|
||||
// Default value contains multiple variable references
|
||||
$input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('API')
|
||||
->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
|
||||
});
|
||||
|
||||
test('nested variables with different operators', function () {
|
||||
// Nested variable uses different operator
|
||||
$input = '${API_URL:-${SERVICE_URL?error message}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
|
||||
});
|
||||
|
||||
test('backward compatibility with simple variables', function () {
|
||||
// Simple variable without nesting should still work
|
||||
$input = '${VAR}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
|
||||
expect($result->value())->toBe('VAR');
|
||||
});
|
||||
|
||||
test('backward compatibility with single-level defaults', function () {
|
||||
// Single-level default without nesting
|
||||
$input = '${VAR:-default_value}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
|
||||
expect($result->value())->toBe('VAR:-default_value');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('default_value');
|
||||
});
|
||||
|
||||
test('backward compatibility with dash operator', function () {
|
||||
$input = '${VAR-default}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe('-');
|
||||
});
|
||||
|
||||
test('backward compatibility with colon question operator', function () {
|
||||
$input = '${VAR:?error message}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe(':?')
|
||||
->and($split['default'])->toBe('error message');
|
||||
});
|
||||
|
||||
test('backward compatibility with question operator', function () {
|
||||
$input = '${VAR?error}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe('?')
|
||||
->and($split['default'])->toBe('error');
|
||||
});
|
||||
|
||||
test('SERVICE_URL magic variables in nested defaults', function () {
|
||||
// Real-world scenario: SERVICE_URL_* magic variable used in nested default
|
||||
$input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('DATABASE_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
|
||||
|
||||
// Extract the nested SERVICE_URL variable
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
|
||||
expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN magic variables in nested defaults', function () {
|
||||
$input = '${API_HOST:-${SERVICE_FQDN_API}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${SERVICE_FQDN_API}');
|
||||
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
|
||||
expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
|
||||
});
|
||||
|
||||
test('complex real-world example', function () {
|
||||
// Complex real-world scenario from the bug report
|
||||
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
|
||||
|
||||
// Step 1: Extract outer variable content
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
// Step 2: Split on operator
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
expect($split['variable'])->toBe('API_URL');
|
||||
expect($split['operator'])->toBe(':-');
|
||||
expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
|
||||
// Step 3: Extract nested variable
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
|
||||
|
||||
// This verifies that:
|
||||
// 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
|
||||
// 2. SERVICE_URL_YOLO should be recognized and created as magic variable
|
||||
});
|
||||
|
||||
test('empty nested default values', function () {
|
||||
$input = '${VAR:-${NESTED:-}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${NESTED:-}');
|
||||
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
$nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
|
||||
|
||||
expect($nestedSplit['default'])->toBe('');
|
||||
});
|
||||
|
||||
test('nested variables with complex paths', function () {
|
||||
$input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
|
||||
});
|
||||
|
||||
test('operator precedence with nesting', function () {
|
||||
// The first :- at depth 0 should be used, not the one inside nested braces
|
||||
$input = '${A:-${B:-default}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
// Should split on first :- (at depth 0)
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-default}'); // Not split here
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertNotNull;
|
||||
use function PHPUnit\Framework\assertNull;
|
||||
|
||||
test('extractBalancedBraceContent extracts content from simple variable', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR')
|
||||
->and($result['start'])->toBe(1)
|
||||
->and($result['end'])->toBe(5);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles nested braces', function () {
|
||||
$result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
|
||||
->and($result['start'])->toBe(1)
|
||||
->and($result['end'])->toBe(34); // Position of closing }
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles triple nesting', function () {
|
||||
$result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('A:-${B:-${C}}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for unbalanced braces', function () {
|
||||
$result = extractBalancedBraceContent('${VAR', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null when no braces', function () {
|
||||
$result = extractBalancedBraceContent('VAR', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles startPos parameter', function () {
|
||||
$result = extractBalancedBraceContent('foo ${VAR} bar', 4);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR')
|
||||
->and($result['start'])->toBe(5)
|
||||
->and($result['end'])->toBe(9);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested splits on :- operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('API_URL:-default_value');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('default_value');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles nested defaults', function () {
|
||||
$split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles dash operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe('-')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles colon question operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:?error message');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':?')
|
||||
->and($split['default'])->toBe('error message');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles question operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR?error');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe('?')
|
||||
->and($split['default'])->toBe('error');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested returns null for simple variable', function () {
|
||||
$split = splitOnOperatorOutsideNested('SIMPLE_VAR');
|
||||
|
||||
assertNull($split);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-${B:-default}');
|
||||
|
||||
assertNotNull($split);
|
||||
// Should split on first :- (outside nested braces), not the one inside ${B:-default}
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-default}');
|
||||
});
|
||||
|
||||
test('replaceVariables handles simple variable', function () {
|
||||
$result = replaceVariables('${VAR}');
|
||||
|
||||
expect($result->value())->toBe('VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables handles nested expressions', function () {
|
||||
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
|
||||
|
||||
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('replaceVariables handles variable with default', function () {
|
||||
$result = replaceVariables('${API_URL:-http://localhost}');
|
||||
|
||||
expect($result->value())->toBe('API_URL:-http://localhost');
|
||||
});
|
||||
|
||||
test('replaceVariables returns unchanged for non-variable string', function () {
|
||||
$result = replaceVariables('not_a_variable');
|
||||
|
||||
expect($result->value())->toBe('not_a_variable');
|
||||
});
|
||||
|
||||
test('replaceVariables handles triple nesting', function () {
|
||||
$result = replaceVariables('${A:-${B:-${C}}}');
|
||||
|
||||
expect($result->value())->toBe('A:-${B:-${C}}');
|
||||
});
|
||||
|
||||
test('replaceVariables fallback works for malformed input', function () {
|
||||
// When braces are unbalanced, it falls back to old behavior
|
||||
$result = replaceVariables('${VAR');
|
||||
|
||||
// Old behavior would extract everything before first }
|
||||
// But since there's no }, it will extract 'VAR' (removing ${)
|
||||
expect($result->value())->toContain('VAR');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles complex nested expression', function () {
|
||||
$result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles complex nested expression', function () {
|
||||
$split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent finds second variable in string', function () {
|
||||
$str = '${VAR1} and ${VAR2}';
|
||||
|
||||
// First variable
|
||||
$result1 = extractBalancedBraceContent($str, 0);
|
||||
assertNotNull($result1);
|
||||
expect($result1['content'])->toBe('VAR1');
|
||||
|
||||
// Second variable
|
||||
$result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
|
||||
assertNotNull($result2);
|
||||
expect($result2['content'])->toBe('VAR2');
|
||||
});
|
||||
|
||||
test('replaceVariables handles empty default value', function () {
|
||||
$result = replaceVariables('${VAR:-}');
|
||||
|
||||
expect($result->value())->toBe('VAR:-');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles empty default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('');
|
||||
});
|
||||
|
||||
test('replaceVariables handles brace format without dollar sign', function () {
|
||||
// This format is used by the regex capture group in magic variable detection
|
||||
$result = replaceVariables('{SERVICE_URL_YOLO}');
|
||||
expect($result->value())->toBe('SERVICE_URL_YOLO');
|
||||
});
|
||||
|
||||
test('replaceVariables handles truncated brace format', function () {
|
||||
// When regex captures {VAR from a larger expression, no closing brace
|
||||
$result = replaceVariables('{API_URL');
|
||||
expect($result->value())->toBe('API_URL');
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
test('parseEnvFormatToArray parses simple KEY=VALUE pairs', function () {
|
||||
$input = "KEY1=value1\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips inline comments from unquoted values', function () {
|
||||
$input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
|
||||
'NODE_VERSION' => ['value' => '22', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () {
|
||||
$input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1#nocomment', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment'],
|
||||
'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves # in quoted values', function () {
|
||||
$input = "KEY1=\"value with # hash\"\nKEY2='another # hash'";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value with # hash', 'comment' => null],
|
||||
'KEY2' => ['value' => 'another # hash', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted values correctly', function () {
|
||||
$input = "KEY1=\"quoted value\"\nKEY2='single quoted'";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'quoted value', 'comment' => null],
|
||||
'KEY2' => ['value' => 'single quoted', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray skips comment lines', function () {
|
||||
$input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray skips empty lines', function () {
|
||||
$input = "KEY1=value1\n\nKEY2=value2\n\n";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles values with equals signs', function () {
|
||||
$input = 'KEY1=value=with=equals';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value=with=equals', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles empty values', function () {
|
||||
$input = "KEY1=\nKEY2=value";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => '', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles complex real-world example', function () {
|
||||
$input = <<<'ENV'
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432 #default postgres port
|
||||
DB_NAME="my_database"
|
||||
DB_PASSWORD='p@ssw0rd#123'
|
||||
|
||||
# API Keys
|
||||
API_KEY=abc123 # Production key
|
||||
SECRET_KEY=xyz789
|
||||
ENV;
|
||||
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DB_HOST' => ['value' => 'localhost', 'comment' => null],
|
||||
'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'],
|
||||
'DB_NAME' => ['value' => 'my_database', 'comment' => null],
|
||||
'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null],
|
||||
'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'],
|
||||
'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles the original bug scenario', function () {
|
||||
$input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
// The value should be "22", not "22 #needed for now"
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22');
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#');
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed');
|
||||
// And the comment should be extracted
|
||||
expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now');
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () {
|
||||
$input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\"";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'],
|
||||
'KEY2' => ['value' => 'another value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () {
|
||||
$input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
// KEY1: no space before #, so entire value is kept
|
||||
// KEY2: space before first #, so everything from first space+# is stripped
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1#not#comment', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () {
|
||||
$input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\"";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => '#starts with hash', 'comment' => null],
|
||||
'KEY2' => ['value' => 'hash # in middle', 'comment' => null],
|
||||
'KEY3' => ['value' => 'ends with hash#', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray trims whitespace before comments', function () {
|
||||
$input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => 'comment'],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'],
|
||||
]);
|
||||
// Values should not have trailing spaces
|
||||
expect($result['KEY1']['value'])->not->toEndWith(' ');
|
||||
expect($result['KEY2']['value'])->not->toEndWith("\t");
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves hash in passwords without spaces', function () {
|
||||
$input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => ['value' => 'pass#word123', 'comment' => null],
|
||||
'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips comments with space before hash', function () {
|
||||
$input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'],
|
||||
'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () {
|
||||
$input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'],
|
||||
'KEY2' => ['value' => 'value', 'comment' => 'another comment'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles empty comments', function () {
|
||||
$input = "KEY1=value #\nKEY2=value # ";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray extracts multi-word comments', function () {
|
||||
$input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () {
|
||||
$input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'],
|
||||
'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'],
|
||||
'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () {
|
||||
$input = 'ASD=asd #asdfgg';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'],
|
||||
]);
|
||||
|
||||
// Specifically verify the comment is extracted
|
||||
expect($result['ASD']['value'])->toBe('asd');
|
||||
expect($result['ASD']['comment'])->toBe('asdfgg');
|
||||
expect($result['ASD']['comment'])->not->toBeNull();
|
||||
});
|
||||
@@ -7,7 +7,6 @@ use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Mockery;
|
||||
|
||||
it('filters servers by proxy type using whereProxyType scope', function () {
|
||||
// Mock the Builder
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Mockery;
|
||||
|
||||
it('returns required port from service template', function () {
|
||||
// Mock get_service_templates() function
|
||||
|
||||
Reference in new issue
Block a user