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