Merge remote-tracking branch 'origin/next' into feat/api-destinations

This commit is contained in:
Andras Bacsai committed 2026-07-02 15:14:13 +02:00
commit 4ef884e1ac
112 files changed
+4523 -1814

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);
});
});
@@ -18,7 +18,9 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -0,0 +1,89 @@
<?php
use App\Http\Middleware\CanUpdateResource;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
function requestWithCanUpdateResourceRouteParameter(string $parameter, ?string $value): Request
{
$parameters = [
'application_uuid' => null,
'database_uuid' => null,
'stack_service_uuid' => null,
'service_uuid' => null,
'server_uuid' => null,
'environment_uuid' => null,
'project_uuid' => null,
$parameter => $value,
];
$request = Mockery::mock(Request::class)->makePartial();
$request->shouldReceive('route')->andReturnUsing(fn (string $key): ?string => $parameters[$key] ?? null);
return $request;
}
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$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']);
});
it('blocks members from update-only project routes before the page renders', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
fn () => response('ok')
);
})->throws(HttpException::class, 'You do not have permission to update this resource.');
it('allows admins through update-only project routes', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$response = (new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
fn () => response('ok')
);
expect($response->getContent())->toBe('ok');
});
it('blocks members from update-only server routes before the page renders', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('server_uuid', $this->server->uuid),
fn () => response('ok')
);
})->throws(HttpException::class, 'You do not have permission to update this resource.');
it('returns not found when an update-only route references an unknown resource', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', 'not-a-project'),
fn () => response('ok')
);
})->throws(NotFoundHttpException::class, 'Resource not found.');
@@ -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();
+124 -2
View File
@@ -4,6 +4,7 @@ use App\Livewire\Project\Database\BackupEdit;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -43,6 +44,20 @@ function createBackupForEditValidationTest(Team $team, array $overrides = []): S
], $overrides));
}
function createS3StorageForBackupEditValidationTest(Team|int $team, string $name = 'Backup Edit S3'): S3Storage
{
return S3Storage::create([
'name' => $name,
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => $team instanceof Team ? $team->id : $team,
]);
}
beforeEach(function () {
if (InstanceSettings::find(0) === null) {
$settings = new InstanceSettings;
@@ -60,7 +75,7 @@ beforeEach(function () {
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -74,7 +89,7 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
'disable_local_backup' => true,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -83,3 +98,110 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
expect($backup->s3_storage_id)->toBeNull();
expect($backup->disable_local_backup)->toBeFalsy();
});
it('keeps S3 enabled by selecting the only available team storage when none is selected yet', function () {
createS3StorageForBackupEditValidationTest(Team::factory()->create());
$s3 = createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('saveS3', true)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('defaults to the first available storage when multiple storages are available', function () {
$firstS3 = createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSet('s3StorageId', $firstS3->id)
->set('saveS3', true)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($firstS3->id);
});
it('accepts the S3 storage scope passed to the component', function () {
$s3 = createS3StorageForBackupEditValidationTest(0);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])])
->set('saveS3', true)
->set('s3StorageId', $s3->id)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('shows available S3 storages even when S3 backup is disabled', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('First S3')
->assertSee('Second S3');
});
it('shows disabled S3 storage dropdown when no storages are available', function () {
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('No S3 storage available');
});
it('shows when S3 backups are currently disabled', function () {
createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('S3 Storage')
->assertSee('(currently disabled)');
});
it('saves selected S3 storage immediately when it changes', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
$secondS3 = createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('s3StorageId', $secondS3->id)
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBe($secondS3->id);
});
@@ -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'",
]);
});
+123
View File
@@ -0,0 +1,123 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\Storage;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$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']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first()
?? Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
});
test('livewire file storage rejects parent segments and does not create a local file volume', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/../../../../../../etc/example.conf')
->set('file_storage_content', 'owned')
->call('submitFileStorage')
->assertDispatched('error');
expect(LocalFileVolume::query()->count())->toBe(0);
});
test('file mount modal shows the calculated host file path above the destination input', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSeeText('This file will be created on the host, then mounted into the container.')
->assertSeeText('Host file path')
->assertSeeText($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf')
->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
->assertDontSeeText('Actual file mounted from the host system to the container.');
});
test('livewire file storage stores safe file mounts under the application configuration root', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/etc/nginx/nginx.conf')
->set('file_storage_content', 'server {}')
->call('submitFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
->and($volume->is_directory)->toBeFalse();
});
test('livewire host file storage stores an existing host file path without managed content', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
->call('submitHostFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->content)->toBeNull()
->and($volume->is_host_file)->toBeTrue()
->and($volume->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
@@ -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);
@@ -262,3 +262,11 @@ test('s3 storage has scheduled backups relationship', function () {
expect($s3->scheduledBackups()->count())->toBe(1);
});
test('database backup job escapes the S3 copy destination argument', function () {
$source = file_get_contents(app_path('Jobs/DatabaseBackupJob.php'));
expect($source)->toContain('$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");')
->and($source)->toContain('mc cp {$escapedBackupLocation} {$escapedS3Destination}')
->and($source)->not->toContain('mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/');
});
+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);
@@ -28,6 +28,7 @@ it('keeps changelog and appearance options out of the preferences dropdown', fun
->toContain('wire:click="openWhatsNewModal"')
->toContain('class="relative text-left menu-item"')
->toContain('class="text-left menu-item-label"')
->toContain('class="absolute right-2 top-1/2 -translate-y-1/2 bg-error')
->toContain("What's New</span>")
->toContain('M9.813 15.904 9 18.75')
->not->toContain('<span>Changelog</span>')
+159 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -7,6 +8,8 @@ use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
@@ -18,8 +21,9 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake();
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -61,6 +65,24 @@ function createTestDatabase($context): StandalonePostgresql
]);
}
function createTestServiceApplication($context): array
{
$service = Service::factory()->create([
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
]);
$serviceApplication = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'test-service-app',
'service_id' => $service->id,
'image' => 'nginx:alpine',
]);
return [$service, $serviceApplication];
}
// ──────────────────────────────────────────────────────────────
// Application Storage Endpoints
// ──────────────────────────────────────────────────────────────
@@ -140,6 +162,56 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/app/config.json');
expect($vol->is_directory)->toBeFalse();
expect($vol->fs_path)->toBe(application_configuration_dir().'/'.$app->uuid.'/app/config.json');
});
test('creates bind only host file storage for application', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'is_host_file' => true,
'fs_path' => '/etc/nginx/nginx.conf',
'mount_path' => '/etc/nginx/nginx.conf',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe('/etc/nginx/nginx.conf');
expect($vol->mount_path)->toBe('/etc/nginx/nginx.conf');
expect($vol->content)->toBeNull();
expect($vol->is_host_file)->toBeTrue();
expect($vol->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
test('rejects file storage paths with parent segments', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'mount_path' => '/../../../../../../etc/example.conf',
'content' => 'owned',
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'Validation failed.');
expect(LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->exists())->toBeFalse();
});
test('rejects persistent storage without name', function () {
@@ -331,6 +403,92 @@ describe('POST /api/v1/databases/{uuid}/storages', function () {
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/extra');
});
test('creates a file storage for a database under the database configuration root', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'file',
'mount_path' => '/postgres/postgresql.conf',
'content' => 'listen_addresses = "*"',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $db->id)
->where('resource_type', get_class($db))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe(database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf');
});
test('rejects file storage paths with parent segments for a database', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'file',
'mount_path' => '/postgres/../../../etc/shadow',
'content' => 'owned',
]);
$response->assertStatus(422);
expect(LocalFileVolume::where('resource_id', $db->id)
->where('resource_type', get_class($db))
->exists())->toBeFalse();
});
});
describe('POST /api/v1/services/{uuid}/storages', function () {
test('creates a file storage for a service resource under the service configuration root', function () {
[$service, $serviceApplication] = createTestServiceApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/storages", [
'type' => 'file',
'resource_uuid' => $serviceApplication->uuid,
'mount_path' => '/etc/nginx/nginx.conf',
'content' => 'server {}',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $serviceApplication->id)
->where('resource_type', get_class($serviceApplication))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe(service_configuration_dir().'/'.$service->uuid.'/etc/nginx/nginx.conf');
});
test('rejects file storage paths with parent segments for a service resource', function () {
[$service, $serviceApplication] = createTestServiceApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/storages", [
'type' => 'file',
'resource_uuid' => $serviceApplication->uuid,
'mount_path' => '/../../../../../../root/.ssh/authorized_keys',
'content' => 'owned',
]);
$response->assertStatus(422);
expect(LocalFileVolume::where('resource_id', $serviceApplication->id)
->where('resource_type', get_class($serviceApplication))
->exists())->toBeFalse();
});
});
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
@@ -143,7 +143,51 @@ describe('checkout.session.completed', function () {
});
});
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
describe('customer.subscription.updated clamps quantity to subscription bounds', function () {
test('quantity below MIN is clamped to 2', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_existing',
'stripe_customer_id' => 'cus_min_clamp_test',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_min_clamp_test',
'id' => 'sub_existing',
'status' => 'active',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
'items' => [
'data' => [[
'subscription' => 'sub_existing',
'plan' => ['id' => 'price_dynamic_monthly'],
'price' => ['lookup_key' => 'dynamic_monthly'],
'quantity' => 1,
]],
],
'cancel_at_period_end' => false,
'cancellation_details' => ['feedback' => null, 'comment' => null],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$this->team->refresh();
expect($this->team->custom_server_limit)->toBe(2);
Queue::assertPushed(ServerLimitCheckJob::class);
});
test('quantity exceeding MAX is clamped to 100', function () {
Queue::fake();
+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();