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

This commit is contained in:
Andras Bacsai committed 2026-06-30 21:31:25 +02:00
commit 559cdbff25
63 files changed
+2286 -1191

No files matched your search

@@ -15,7 +15,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -252,3 +252,94 @@ describe('PATCH /api/v1/applications/{uuid}/envs', function () {
$response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
});
});
describe('environment variable key validation for app and service APIs', function () {
test('rejects invalid service environment variable keys on create update and bulk', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
test('rejects invalid application environment variable keys on create update and bulk', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
});
@@ -161,6 +161,33 @@ test('member cannot update email notification settings', function () {
->assertForbidden();
});
test('member cannot update smtp email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('smtpPassword', 'member-smtp-password')
->call('submitSmtp')
->assertForbidden();
});
test('member cannot update resend email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('resendApiKey', 'member-resend-api-key')
->call('submitResend')
->assertForbidden();
});
test('member cannot copy instance email settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
@@ -312,6 +339,10 @@ test('member cannot view notification secrets', function (string $component, str
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/secret-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-secret-password',
'resend_api_key' => 'resend-secret-api-key',
]],
]);
test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
@@ -346,4 +377,8 @@ test('admin can view notification secrets', function (string $component, string
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/admin-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-admin-password',
'resend_api_key' => 'resend-admin-api-key',
]],
]);
@@ -0,0 +1,133 @@
<?php
use App\Http\Middleware\CanCreateResources;
use App\Livewire\Project\New\DockerCompose;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.store' => 'array',
'cache.default' => 'array',
]);
InstanceSettings::query()->forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
});
test('member cannot pass create resources middleware', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$middleware = new CanCreateResources;
$request = Request::create('/project/new', 'GET');
expect(fn () => $middleware->handle($request, fn () => response('ok')))
->toThrow(HttpException::class, 'You do not have permission to create resources.');
});
test('admin can pass create resources middleware', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$middleware = new CanCreateResources;
$request = Request::create('/project/new', 'GET');
$response = $middleware->handle($request, fn () => response('ok'));
expect($response->getStatusCode())->toBe(200);
});
test('member cannot create docker compose service through livewire action', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(DockerCompose::class)
->set('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('query', ['destination' => $this->destination->uuid])
->set('dockerComposeRaw', <<<'YAML'
services:
app:
image: alpine
YAML)
->call('submit')
->assertDispatched('error');
expect(Service::query()->count())->toBe(0);
});
test('public git docker compose creates an application in local mode', function () {
config(['app.env' => 'local']);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(PublicGitRepository::class)
->set('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('query', ['destination' => $this->destination->uuid])
->set('repository_url', 'https://github.com/coollabsio/coolify')
->set('git_repository', 'https://github.com/coollabsio/coolify')
->set('git_branch', 'main')
->set('build_pack', 'dockercompose')
->set('new_compose_services', true)
->call('submit');
expect(Application::query()->count())->toBe(1)
->and(Service::query()->count())->toBe(0);
});
@@ -323,5 +323,5 @@ test('member cannot update an existing team at model level', function () {
session(['currentTeam' => $this->team]);
expect(fn () => $this->team->update(['name' => 'Hacked']))
->toThrow(\Exception::class, 'You are not allowed to update this team.');
->toThrow(Exception::class, 'You are not allowed to update this team.');
});
@@ -13,12 +13,19 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Once;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->withoutVite();
Once::flush();
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
@@ -159,7 +166,8 @@ test('member gets 403 from POST /upload/backup and no file lands on disk', funct
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response = $this->withHeader('Accept', 'application/json')
->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertForbidden();
@@ -1,6 +1,28 @@
<?php
use App\Http\Controllers\UploadController;
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandalonePostgresql;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Process;
function writeScanPayload(string $content, bool $gzip = false): string
{
$path = tempnam(sys_get_temp_dir(), 'coolify-scan-payload-');
file_put_contents($path, $gzip ? gzencode($content) : $content);
return $path;
}
/**
* Execute the real shell scanner snippet (as it runs inside the container)
* against a local payload file and return true when the restore is blocked.
*/
function scannerBlocks(string $script): bool
{
return Process::run(['sh', '-c', $script])->exitCode() === 1;
}
function invokeHasAllowedExtension(string $name): bool
{
@@ -10,6 +32,28 @@ function invokeHasAllowedExtension(string $name): bool
return $method->invoke(null, $name);
}
function backupValidationImportFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
function makeTemporaryUpload(string $name, string $content): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'coolify-upload-test-');
file_put_contents($path, $content);
return new UploadedFile($path, $name, null, null, true);
}
test('hasAllowedExtension accepts supported extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeTrue();
})->with([
@@ -46,6 +90,140 @@ test('hasAllowedExtension rejects unsupported or empty stems', function (string
'misleading double ext' => ['shell.php.sql-evil'],
]);
test('hasAllowedExtension rejects dangerous double extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeFalse();
})->with([
'php sql' => ['evil.php.sql'],
'php gzip' => ['evil.php.gz'],
'shell tar' => ['evil.sh.tar'],
'php tar gzip' => ['shell.php.tar.gz'],
'exe zip' => ['cmd.exe.zip'],
'jsp sql' => ['evil.jsp.sql'],
]);
test('backup validator rejects content that does not match the backup extension', function () {
$file = makeTemporaryUpload('payload.sql.gz', 'not actually gzip');
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('backup validator accepts valid plain sql and gzip backup content', function () {
$plainSql = makeTemporaryUpload('backup.sql', "CREATE TABLE users (id integer);\n");
$gzipSql = makeTemporaryUpload('backup.sql.gz', gzencode("CREATE TABLE users (id integer);\n"));
expect(DatabaseBackupFileValidator::isUploadAllowed($plainSql, 10 * 1024 * 1024))->toBeTrue()
->and(DatabaseBackupFileValidator::isUploadAllowed($gzipSql, 10 * 1024 * 1024))->toBeTrue();
});
test('postgresql backup safety scanner detects program execution payloads', function (string $payload) {
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue();
})->with([
'copy from program' => ["COPY pwned FROM PROGRAM 'id';"],
'copy to program' => ["COPY pwned TO PROGRAM 'cat > /tmp/out';"],
'copy with block comment' => ["COPY pwned FROM/**/PROGRAM 'id';"],
'psql shell command' => ["\\! id\n"],
'psql copy program' => ["\\copy pwned from program 'id'\n"],
]);
test('postgresql backup safety scanner allows ordinary sql dumps', function () {
$dump = <<<'SQL'
-- PostgreSQL database dump
CREATE TABLE users (id integer, name text);
COPY users (id, name) FROM stdin;
1 Taylor
\.
SQL;
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse();
});
test('postgresql restore commands include a safety check before execution', function () {
$component = new class extends ImportForm
{
public function __get($property)
{
if ($property === 'resource') {
return new class
{
public function getMorphClass(): string
{
return StandalonePostgresql::class;
}
};
}
return parent::__get($property);
}
};
$component->container = 'postgres-test';
$command = $component->buildRestoreSafetyCheckCommand('/tmp/restore_test');
expect($command)
->toContain('docker exec postgres-test')
->toContain('COPY ... PROGRAM')
->toContain('/tmp/restore_test')
->toContain('grep -Eiq');
});
test('non postgresql restore commands do not include a safety check', function () {
$component = backupValidationImportFormWithResource('App\Models\StandaloneMysql');
$component->container = 'mysql-test';
expect($component->buildRestoreSafetyCheckCommand('/tmp/restore_test'))->toBeNull();
});
test('file scanner detects program execution payloads inside gzipped backups', function () {
$gzPayload = writeScanPayload("CREATE TABLE x();\nCOPY x FROM/**/PROGRAM 'id';\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzPayload))->toBeTrue();
});
test('file scanner allows ordinary gzipped dumps', function () {
$gzClean = writeScanPayload("CREATE TABLE x();\nCOPY x FROM stdin;\n1\\.\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse();
});
test('backup validator rejects plaintext .dump containing program execution', function () {
$file = makeTemporaryUpload('evil.dump', "COPY x FROM PROGRAM 'id';\n");
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('remote postgresql scanner blocks bypass payloads', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeTrue();
})->with([
'psql shell escape' => ["\\! id\n", false],
'copy from program' => ["COPY x FROM PROGRAM 'id';\n", false],
'copy with block comment' => ["COPY x FROM/**/PROGRAM 'id';\n", false],
'copy split across lines' => ["COPY x FROM\nPROGRAM 'id';\n", false],
'copy to program' => ["COPY x TO PROGRAM 'cat > /tmp/x';\n", false],
'psql pipe redirect' => ["\\o | id\n", false],
'gzipped comment bypass' => ["COPY x FROM/**/PROGRAM 'id';\n", true],
]);
test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeFalse();
})->with([
'commented out payload' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n", false],
'copy from stdin' => ["COPY users FROM stdin;\n1\tTaylor\n\\.\n", false],
'plain select' => ["SELECT * FROM users;\n", false],
'gzipped clean dump' => ["CREATE TABLE users (id int);\n", true],
]);
test('MAX_BYTES constant is 10 GiB', function () {
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
expect($constant)->toBe(10 * 1024 * 1024 * 1024);
@@ -14,7 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -344,3 +344,44 @@ describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () {
$response->assertStatus(404);
});
});
describe('environment variable key validation for database APIs', function () {
test('rejects invalid database environment variable keys on create update and bulk', function () {
$database = createDatabase($this);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
});
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@@ -42,6 +43,7 @@ it('can seed the railpack examples directly on a clean development database', fu
expect(Server::query()->find(0))->not->toBeNull();
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
expect(GithubApp::query()->find(0))->not->toBeNull();
expect(GitlabApp::query()->find(1))->not->toBeNull();
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
@@ -66,9 +68,10 @@ it('seeds the railpack examples in development mode', function () {
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
expect($applications->every(
fn (Application $application) => $application->git_repository === ($examples->get($application->uuid)['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
))->toBeTrue();
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
@@ -79,6 +82,9 @@ it('seeds the railpack examples in development mode', function () {
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'railpack-rust');
$githubDeployKey = $applications->firstWhere('uuid', 'railpack-github-deploy-key');
$gitlabDeployKey = $applications->firstWhere('uuid', 'railpack-gitlab-deploy-key');
$gitlabPublic = $applications->firstWhere('uuid', 'railpack-gitlab-public-example');
expect($nestjs)
->not->toBeNull()
@@ -113,6 +119,33 @@ it('seeds the railpack examples in development mode', function () {
expect($rust)
->not->toBeNull()
->and($rust->ports_exposes)->toBe('8000');
expect($githubDeployKey)
->not->toBeNull()
->and($githubDeployKey->git_repository)->toBe('git@github.com:coollabsio/coolify-examples-deploy-key.git')
->and($githubDeployKey->git_branch)->toBe('main')
->and($githubDeployKey->build_pack)->toBe('railpack')
->and($githubDeployKey->private_key_id)->toBe(1)
->and($githubDeployKey->source_type)->toBe(GithubApp::class)
->and($githubDeployKey->source_id)->toBe(0);
expect($gitlabDeployKey)
->not->toBeNull()
->and($gitlabDeployKey->git_repository)->toBe('git@gitlab.com:coollabsio/php-example.git')
->and($gitlabDeployKey->git_branch)->toBe('main')
->and($gitlabDeployKey->build_pack)->toBe('railpack')
->and($gitlabDeployKey->private_key_id)->toBe(1)
->and($gitlabDeployKey->source_type)->toBe(GitlabApp::class)
->and($gitlabDeployKey->source_id)->toBe(1);
expect($gitlabPublic)
->not->toBeNull()
->and($gitlabPublic->git_repository)->toBe('https://gitlab.com/andrasbacsai/coolify-examples.git')
->and($gitlabPublic->base_directory)->toBe('/astro/static')
->and($gitlabPublic->publish_directory)->toBe('/dist')
->and($gitlabPublic->build_pack)->toBe('railpack')
->and($gitlabPublic->source_type)->toBe(GitlabApp::class)
->and($gitlabPublic->settings->is_static)->toBeTrue();
});
it('skips the railpack examples outside development mode', function () {
@@ -9,8 +9,8 @@ test('generateDockerBuildArgs returns only keys without values', function () {
$buildArgs = generateDockerBuildArgs($variables);
// Docker gets values from the environment, so only keys should be in build args
expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY');
expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR');
expect($buildArgs->first())->toBe("--build-arg 'SSH_PRIVATE_KEY'");
expect($buildArgs->last())->toBe("--build-arg 'REGULAR_VAR'");
});
test('generateDockerBuildArgs works with collection of objects', function () {
@@ -22,8 +22,8 @@ test('generateDockerBuildArgs works with collection of objects', function () {
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs)->toHaveCount(2);
expect($buildArgs->values()->toArray())->toBe([
'--build-arg VAR1',
'--build-arg VAR2',
"--build-arg 'VAR1'",
"--build-arg 'VAR2'",
]);
});
@@ -38,7 +38,7 @@ test('generateDockerBuildArgs collection can be imploded into valid command stri
// The collection must be imploded to a string for command interpolation
// This was the bug: Collection was interpolated as JSON instead of a space-separated string
$argsString = $buildArgs->implode(' ');
expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH');
expect($argsString)->toBe("--build-arg 'COOLIFY_URL' --build-arg 'COOLIFY_BRANCH'");
// Verify it does NOT produce JSON when cast to string
expect($argsString)->not->toContain('{');
@@ -53,7 +53,7 @@ test('generateDockerBuildArgs handles variables without is_multiline', function
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
expect($arg)->toBe('--build-arg NO_FLAG_VAR');
expect($arg)->toBe("--build-arg 'NO_FLAG_VAR'");
});
test('generateDockerEnvFlags produces correct format', function () {
@@ -81,3 +81,17 @@ test('generateDockerEnvFlags works with collection input', function () {
expect($envFlags)->toContain('-e VAR1=');
expect($envFlags)->toContain('-e VAR2="');
});
test('generateDockerBuildArgs escapes legacy keys', function () {
$variables = [
['key' => 'BAD$(id)', 'value' => '1'],
['key' => "BAD'KEY", 'value' => '1'],
];
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs->values()->toArray())->toBe([
"--build-arg 'BAD$(id)'",
"--build-arg 'BAD'\''KEY'",
]);
});
@@ -1,11 +1,15 @@
<?php
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
@@ -14,7 +18,7 @@ beforeEach(function () {
});
it('detects servers table has detected_traefik_version column', function () {
expect(\Illuminate\Support\Facades\Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue();
expect(Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue();
});
it('server model casts detected_traefik_version as string', function () {
@@ -137,7 +141,7 @@ it('notification channels can be retrieved', function () {
});
it('traefik version check command exists', function () {
$commands = \Illuminate\Support\Facades\Artisan::all();
$commands = Artisan::all();
expect($commands)->toHaveKey('traefik:check-version');
});
@@ -181,16 +185,16 @@ it('groups servers by team correctly', function () {
});
it('server check job exists and has correct structure', function () {
expect(class_exists(\App\Jobs\CheckTraefikVersionForServerJob::class))->toBeTrue();
expect(class_exists(CheckTraefikVersionForServerJob::class))->toBeTrue();
// Verify CheckTraefikVersionForServerJob has required properties
$reflection = new \ReflectionClass(\App\Jobs\CheckTraefikVersionForServerJob::class);
$reflection = new ReflectionClass(CheckTraefikVersionForServerJob::class);
expect($reflection->hasProperty('tries'))->toBeTrue();
expect($reflection->hasProperty('timeout'))->toBeTrue();
// Verify it implements ShouldQueue
$interfaces = class_implements(\App\Jobs\CheckTraefikVersionForServerJob::class);
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class);
$interfaces = class_implements(CheckTraefikVersionForServerJob::class);
expect($interfaces)->toContain(ShouldQueue::class);
});
it('sends immediate notifications when outdated traefik is detected', function () {
+1 -1
View File
@@ -42,7 +42,7 @@ test('is queued on the high priority queue', function () {
});
test('marks activity as error on permanent failure', function () {
$exception = new \RuntimeException('SSH connection failed');
$exception = new RuntimeException('SSH connection failed');
$this->job->failed($exception);
+3 -2
View File
@@ -1,5 +1,6 @@
<?php
use App\Enums\ProxyTypes;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
@@ -35,7 +36,7 @@ function makeServerProxyRunning(Server $server): void
'is_usable' => true,
]);
$server->proxy->status = 'running';
$server->proxy->type = \App\Enums\ProxyTypes::TRAEFIK->value;
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
}
@@ -75,7 +76,7 @@ test('member cannot see start proxy button', function () {
[$user, $team, $server] = setupProxyUser('member');
$server->proxy->status = 'exited';
$server->proxy->type = \App\Enums\ProxyTypes::TRAEFIK->value;
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
+4 -2
View File
@@ -1,5 +1,7 @@
<?php
use App\Rules\ValidIpOrCidr;
test('IP allowlist with single IPs', function () {
$testCases = [
['ip' => '192.168.1.100', 'allowlist' => ['192.168.1.100'], 'expected' => true],
@@ -200,7 +202,7 @@ test('IP allowlist comma-separated string input', function () {
});
test('ValidIpOrCidr validation rule', function () {
$rule = new \App\Rules\ValidIpOrCidr;
$rule = new ValidIpOrCidr;
// Helper function to test validation
$validate = function ($value) use ($rule) {
@@ -248,7 +250,7 @@ test('ValidIpOrCidr validation rule', function () {
});
test('ValidIpOrCidr validation rule error messages', function () {
$rule = new \App\Rules\ValidIpOrCidr;
$rule = new ValidIpOrCidr;
// Helper function to get error message
$getError = function ($value) use ($rule) {
@@ -2,9 +2,10 @@
use App\Http\Middleware\TrustHosts;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
uses(RefreshDatabase::class);
beforeEach(function () {
// Clear cache before each test to ensure isolation
@@ -84,7 +85,7 @@ it('extracts host from FQDN with protocol and port', function () {
it('handles exception during InstanceSettings fetch', function () {
// Drop the instance_settings table to simulate installation
\Schema::dropIfExists('instance_settings');
Schema::dropIfExists('instance_settings');
$middleware = new TrustHosts($this->app);
+33
View File
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
@@ -100,6 +101,38 @@ describe('GitHub Manual Webhook HMAC', function () {
});
});
describe('GitHub App Webhook HMAC', function () {
test('rejects push when app webhook secret is empty', function () {
$team = Team::factory()->create();
GithubApp::create([
'uuid' => (string) str()->uuid(),
'name' => 'github-app-webhook-test',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 1234567890,
'webhook_secret' => null,
'team_id' => $team->id,
]);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['id' => 987654321],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
});
describe('GitLab Manual Webhook HMAC', function () {
test('rejects push when secret is empty', function () {
$app = createApplicationWithWebhook();