mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 18:15:24 +00:00
Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber
This commit is contained in:
commit
ff976a134f
136 files changed
+6142
-1733
No files matched your search
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
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\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'source-commit-api-test-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function sourceCommitSettingApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} include_source_commit_in_build', function () {
|
||||
test('updates the application setting through the API', function () {
|
||||
expect((bool) $this->application->settings->include_source_commit_in_build)->toBeFalse();
|
||||
|
||||
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'include_source_commit_in_build' => true,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect((bool) $this->application->fresh()->settings->include_source_commit_in_build)->toBeTrue();
|
||||
|
||||
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'include_source_commit_in_build' => false,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect((bool) $this->application->fresh()->settings->include_source_commit_in_build)->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects non boolean values', function () {
|
||||
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'include_source_commit_in_build' => 'not-a-boolean',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('include_source_commit_in_build');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -13,10 +14,7 @@ beforeEach(function () {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
config()->set('cache.default', 'array');
|
||||
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
// Create a team with owner
|
||||
$this->team = Team::factory()->create();
|
||||
@@ -29,7 +27,9 @@ beforeEach(function () {
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Create a private key for the team
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validGithubAppsApiPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
@@ -41,6 +41,26 @@ function createGithubAppsApiToken($context, array $abilities): string
|
||||
return $context->user->createToken('github-apps-test-token', $abilities)->plainTextToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a temporary 2048-bit RSA private key for GitHub Apps API tests.
|
||||
*
|
||||
* Generated in-process so tests do not depend on external files or secrets;
|
||||
* the key is used only as a signing fixture and is never persisted.
|
||||
*
|
||||
* @return string PEM-encoded RSA private key
|
||||
*/
|
||||
function validGithubAppsApiPrivateKey(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
describe('GET /api/v1/github-apps', function () {
|
||||
test('returns 401 when not authenticated', function () {
|
||||
$response = $this->getJson('/api/v1/github-apps');
|
||||
@@ -195,6 +215,11 @@ describe('GET /api/v1/github-apps', function () {
|
||||
|
||||
// Create another team with a GitHub app
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherPrivateKey = PrivateKey::create([
|
||||
'name' => 'Other Key',
|
||||
'private_key' => validGithubAppsApiPrivateKey(),
|
||||
'team_id' => $otherTeam->id,
|
||||
]);
|
||||
GithubApp::create([
|
||||
'name' => 'Team 2 App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
@@ -204,7 +229,7 @@ describe('GET /api/v1/github-apps', function () {
|
||||
'client_id' => 'team2-client-id',
|
||||
'client_secret' => 'team2-secret',
|
||||
'webhook_secret' => 'team2-webhook',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'private_key_id' => $otherPrivateKey->id,
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
@@ -260,3 +285,179 @@ describe('GET /api/v1/github-apps', function () {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub app API url normalization', function () {
|
||||
test('normalizes ghe dot com api url when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => '/octocorp/',
|
||||
'html_url' => 'https://github.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonFragment([
|
||||
'organization' => 'octocorp',
|
||||
'api_url' => 'https://api.github.ghe.com',
|
||||
'html_url' => 'https://github.ghe.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves provided api url when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => '/octocorp/',
|
||||
'api_url' => 'https://github.com/api/v3',
|
||||
'html_url' => 'https://github.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonFragment([
|
||||
'organization' => 'octocorp',
|
||||
'api_url' => 'https://github.com/api/v3',
|
||||
'html_url' => 'https://github.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves provided api url when updating github apps', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://github.company.internal/api/v3',
|
||||
'html_url' => 'https://github.company.internal',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
|
||||
'html_url' => 'https://github.com',
|
||||
'api_url' => 'https://github.com/api/v3',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
|
||||
});
|
||||
|
||||
test('preserves provided api url when updating api url only', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
|
||||
'api_url' => 'https://github.com/api/v3',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
|
||||
});
|
||||
|
||||
test('rejects invalid organization when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => 'octo/corp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['organization']);
|
||||
});
|
||||
|
||||
test('loads repositories and branches through normalized ghe dot com api url', function () {
|
||||
$this->privateKey->update([
|
||||
'private_key' => validGithubAppsApiPrivateKey(),
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/app/installations/67890/access_tokens' => Http::response([
|
||||
'token' => 'installation-token',
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/installation/repositories*' => Http::response([
|
||||
'repositories' => [
|
||||
['name' => 'repo', 'full_name' => 'octocorp/repo'],
|
||||
],
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/repos/octocorp/repo/branches' => Http::response([
|
||||
['name' => 'main'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('repositories.0.name', 'repo');
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories/octocorp/repo/branches")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('branches.0.name', 'main');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/installation/repositories?per_page=100&page=1');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/repos/octocorp/repo/branches');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
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\Testing\Fluent\AssertableJson;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake([ApplicationDeploymentJob::class]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_api_enabled' => true],
|
||||
));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('deployment-actions-test', ['deploy'])->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function deploymentActionHeaders(string $token): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function makeDeploymentActionApplication(Environment $environment, StandaloneDocker $destination): Application
|
||||
{
|
||||
return Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
}
|
||||
|
||||
test('application start API returns queued deployment uuid as a string', function () {
|
||||
$application = makeDeploymentActionApplication($this->environment, $this->destination);
|
||||
|
||||
$response = $this->withHeaders(deploymentActionHeaders($this->token))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/start");
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJson(fn (AssertableJson $json) => $json
|
||||
->where('message', 'Deployment request queued.')
|
||||
->whereType('deployment_uuid', 'string')
|
||||
);
|
||||
|
||||
expect(ApplicationDeploymentQueue::query()->where('deployment_uuid', $response->json('deployment_uuid'))->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('application restart API returns queued deployment uuid as a string', function () {
|
||||
$application = makeDeploymentActionApplication($this->environment, $this->destination);
|
||||
|
||||
$response = $this->withHeaders(deploymentActionHeaders($this->token))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/restart");
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJson(fn (AssertableJson $json) => $json
|
||||
->where('message', 'Restart request queued.')
|
||||
->whereType('deployment_uuid', 'string')
|
||||
);
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::query()
|
||||
->where('deployment_uuid', $response->json('deployment_uuid'))
|
||||
->first();
|
||||
|
||||
expect($deployment)->not->toBeNull()
|
||||
->and($deployment->restart_only)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('deployment uuid strings are not converted as objects in API and webhook controllers', function () {
|
||||
$files = [
|
||||
app_path('Http/Controllers/Api/DeployController.php'),
|
||||
app_path('Http/Controllers/Webhook/Bitbucket.php'),
|
||||
app_path('Http/Controllers/Webhook/Gitea.php'),
|
||||
app_path('Http/Controllers/Webhook/Gitlab.php'),
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
expect(file_get_contents($file))
|
||||
->not->toContain('$deployment_uuid->toString()')
|
||||
->not->toContain('$deployment_uuid?->toString()');
|
||||
}
|
||||
});
|
||||
@@ -147,6 +147,140 @@ describe('GitHub Source Change Component', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('ghe.com installation path uses github cloud owner scoped route', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'provided-github-app',
|
||||
'organization' => 'acme-enterprise',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||
$installState = $query['state'] ?? null;
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/provided-github-app/installations/new?')
|
||||
->and($installState)->not->toBeEmpty()
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
|
||||
->toMatchArray([
|
||||
'action' => 'install',
|
||||
'github_app_id' => 123,
|
||||
'team_id' => 456,
|
||||
]);
|
||||
});
|
||||
|
||||
test('installation path is pure and never calls github or mutates the app', function () {
|
||||
Http::fake();
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'github-app-local-name',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Local Display Name',
|
||||
'organization' => 'acme-enterprise',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/local-display-name/installations/new?')
|
||||
->and($githubApp->refresh()->name)->toBe('Local Display Name')
|
||||
->and($privateKey->refresh()->name)->toBe('github-app-local-name');
|
||||
});
|
||||
|
||||
test('syncGithubAppName persists the github slug and renames the private key', function () {
|
||||
Http::fake([
|
||||
'*/app' => Http::response(['slug' => 'actual-github-slug']),
|
||||
'*/zen' => Http::response('Keep it logically awesome.'),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'github-app-local-name',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Local Display Name',
|
||||
'organization' => 'acme-enterprise',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
$appSlug = syncGithubAppName($githubApp, true);
|
||||
|
||||
expect($appSlug)->toBe('actual-github-slug')
|
||||
->and($githubApp->refresh()->name)->toBe('actual-github-slug')
|
||||
->and($privateKey->refresh()->name)->toBe('github-app-actual-github-slug');
|
||||
});
|
||||
|
||||
test('ghe.com installation path encodes the organization segment', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'provided-github-app',
|
||||
'organization' => '/acme enterprise/',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?');
|
||||
});
|
||||
|
||||
test('ghe.com installation path encodes plus signs in the organization segment', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'Provided GitHub App',
|
||||
'organization' => 'octo+corp',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
|
||||
});
|
||||
|
||||
test('ghe.com installation path keeps app scoped fallback when organization is blank', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'provided-github-app',
|
||||
'organization' => null,
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/provided-github-app/installations/new?');
|
||||
});
|
||||
|
||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
@@ -207,6 +341,10 @@ describe('GitHub Source Change Component', function () {
|
||||
});
|
||||
|
||||
test('can mount with fully configured github app', function () {
|
||||
Http::fake([
|
||||
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
@@ -241,6 +379,10 @@ describe('GitHub Source Change Component', function () {
|
||||
});
|
||||
|
||||
test('can update github app from null to valid values', function () {
|
||||
Http::fake([
|
||||
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
@@ -277,6 +419,49 @@ describe('GitHub Source Change Component', function () {
|
||||
expect($githubApp->private_key_id)->toBe($privateKey->id);
|
||||
});
|
||||
|
||||
test('preserves custom ghe api url when saving github app settings', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://github.ghe.com/api/v3',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->set('htmlUrl', 'https://github.ghe.com')
|
||||
->set('apiUrl', 'https://github.ghe.com/api/v3')
|
||||
->call('submit')
|
||||
->assertDispatched('success')
|
||||
->assertSet('apiUrl', 'https://github.ghe.com/api/v3');
|
||||
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
|
||||
});
|
||||
|
||||
test('rejects invalid github organization values', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->set('organization', 'octo/corp')
|
||||
->call('submit')
|
||||
->assertHasErrors(['organization']);
|
||||
});
|
||||
|
||||
test('validation allows nullable values for app configuration', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
@@ -429,4 +614,43 @@ describe('GitHub Source Change Component', function () {
|
||||
->and($githubApp->metadata)->toBe('read')
|
||||
->and($githubApp->pull_requests)->toBe('write');
|
||||
});
|
||||
|
||||
test('sync name uses normalized resolvable ghe dot com api url', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.ghe.com',
|
||||
'html_url' => 'https://github.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.ghe.com/app' => Http::response([
|
||||
'slug' => 'octocorp-app',
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->call('updateGithubAppName')
|
||||
->assertDispatched('success');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app');
|
||||
});
|
||||
});
|
||||
@@ -72,8 +72,8 @@ describe('GitHub Source Create Component', function () {
|
||||
Livewire::test(Create::class)
|
||||
->assertSuccessful()
|
||||
->set('name', 'enterprise-app')
|
||||
->set('api_url', 'https://github.enterprise.com/api/v3')
|
||||
->set('html_url', 'https://github.enterprise.com')
|
||||
->set('api_url', 'https://github.ghe.com/api/v3')
|
||||
->set('html_url', 'https://github.ghe.com')
|
||||
->set('custom_user', 'git-custom')
|
||||
->set('custom_port', 2222)
|
||||
->call('createGitHubApp')
|
||||
@@ -82,12 +82,28 @@ describe('GitHub Source Create Component', function () {
|
||||
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
|
||||
|
||||
expect($githubApp)->not->toBeNull();
|
||||
expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3');
|
||||
expect($githubApp->html_url)->toBe('https://github.enterprise.com');
|
||||
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
|
||||
expect($githubApp->html_url)->toBe('https://github.ghe.com');
|
||||
expect($githubApp->custom_user)->toBe('git-custom');
|
||||
expect($githubApp->custom_port)->toBe(2222);
|
||||
});
|
||||
|
||||
test('preserves custom github enterprise api url when creating github app', function () {
|
||||
Livewire::test(Create::class)
|
||||
->assertSuccessful()
|
||||
->set('name', 'ghe-custom-api-app')
|
||||
->set('api_url', 'https://github.ghe.com/api/v3')
|
||||
->set('html_url', 'https://github.ghe.com')
|
||||
->call('createGitHubApp')
|
||||
->assertRedirect();
|
||||
|
||||
$githubApp = GithubApp::where('name', 'ghe-custom-api-app')->first();
|
||||
|
||||
expect($githubApp)->not->toBeNull();
|
||||
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
|
||||
expect($githubApp->html_url)->toBe('https://github.ghe.com');
|
||||
});
|
||||
|
||||
test('validates required fields', function () {
|
||||
Livewire::test(Create::class)
|
||||
->assertSuccessful()
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use phpseclib3\Crypt\EC;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
|
||||
$ecKey = EC::createKey('Ed25519');
|
||||
$privateKeyContent = $ecKey->toString('OpenSSH');
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'test-key',
|
||||
'private_key' => $privateKeyContent,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
|
||||
ServerSetting::create([
|
||||
'server_id' => $this->server->id,
|
||||
'wildcard_domain' => 'http://127.0.0.1.sslip.io',
|
||||
]);
|
||||
|
||||
$this->destination = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'network' => 'test-network-'.fake()->uuid(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN variables', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
backend:
|
||||
image: myapp/backend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_FRONTEND=${FRONTEND_URL}
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->not->toBeNull()
|
||||
->and($domains)->toBeArray()
|
||||
->and($domains)->toHaveKey('backend')
|
||||
->and($domains['backend'])->toHaveKey('domain')
|
||||
->and($domains['backend']['domain'])->toStartWith('http://')
|
||||
->and($domains['backend']['domain'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN without port', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_FRONTEND=${FRONTEND_URL}
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->not->toBeNull()
|
||||
->and($domains)->toHaveKey('frontend')
|
||||
->and($domains['frontend'])->toHaveKey('domain');
|
||||
});
|
||||
|
||||
test('applicationParser does not populate docker_compose_domains for non-dockercompose build_pack', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
backend:
|
||||
image: myapp/backend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockerfile',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
// For non-dockercompose, docker_compose_domains should remain empty
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
expect($domains)->toBeNull();
|
||||
});
|
||||
|
||||
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_URL variables', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
- SERVICE_URL_FRONTEND=/ui
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->not->toBeNull()
|
||||
->and($domains)->toHaveKey('frontend')
|
||||
->and($domains['frontend']['domain'])->toStartWith('http://')
|
||||
->and($domains['frontend']['domain'])->toEndWith('/ui');
|
||||
});
|
||||
|
||||
test('applicationParser preserves existing docker_compose_domains entries', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
backend:
|
||||
image: myapp/backend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
- SERVICE_URL_FRONTEND=/ui
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://existing.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains['frontend']['domain'])->toBe('https://existing.example.com')
|
||||
->and($domains)->toHaveKey('backend')
|
||||
->and($domains['backend']['domain'])->toStartWith('http://');
|
||||
});
|
||||
|
||||
test('applicationParser handles other docker compose domain shapes without regressions', function () {
|
||||
$createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
|
||||
return Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => $dockerComposeDomains,
|
||||
]);
|
||||
};
|
||||
|
||||
$valueBasedCompose = <<<'YAML'
|
||||
services:
|
||||
backend:
|
||||
image: myapp/backend:latest
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
API_URL: ${SERVICE_URL_BACKEND}
|
||||
YAML;
|
||||
|
||||
$valueBasedApplication = $createApplication($valueBasedCompose);
|
||||
applicationParser($valueBasedApplication);
|
||||
$valueBasedApplication->refresh();
|
||||
$valueBasedDomains = json_decode($valueBasedApplication->docker_compose_domains, true);
|
||||
|
||||
expect($valueBasedDomains)->toHaveKey('backend')
|
||||
->and($valueBasedDomains['backend']['domain'])->toStartWith('http://')
|
||||
->and($valueBasedDomains)->not->toHaveKey('frontend');
|
||||
|
||||
$mapStyleCompose = <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
SERVICE_URL_FRONTEND: /ui
|
||||
YAML;
|
||||
|
||||
$mapStyleApplication = $createApplication($mapStyleCompose);
|
||||
applicationParser($mapStyleApplication);
|
||||
$mapStyleApplication->refresh();
|
||||
$mapStyleDomains = json_decode($mapStyleApplication->docker_compose_domains, true);
|
||||
|
||||
expect($mapStyleDomains)->toHaveKey('frontend')
|
||||
->and($mapStyleDomains['frontend']['domain'])->toEndWith('/ui');
|
||||
|
||||
$missingServiceCompose = <<<'YAML'
|
||||
services:
|
||||
worker:
|
||||
image: myapp/worker:latest
|
||||
environment:
|
||||
SERVICE_URL_API: /api
|
||||
YAML;
|
||||
|
||||
$missingServiceApplication = $createApplication($missingServiceCompose);
|
||||
applicationParser($missingServiceApplication);
|
||||
$missingServiceApplication->refresh();
|
||||
|
||||
expect(json_decode($missingServiceApplication->docker_compose_domains, true))->toBeNull();
|
||||
|
||||
$plainCompose = <<<'YAML'
|
||||
services:
|
||||
worker:
|
||||
image: myapp/worker:latest
|
||||
environment:
|
||||
FOO: bar
|
||||
YAML;
|
||||
|
||||
$plainApplication = $createApplication($plainCompose);
|
||||
applicationParser($plainApplication);
|
||||
$plainApplication->refresh();
|
||||
|
||||
expect(json_decode($plainApplication->docker_compose_domains, true))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
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 Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
|
||||
['network' => 'coolify'],
|
||||
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
|
||||
);
|
||||
});
|
||||
|
||||
$this->project = Project::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'test-project',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->environment = $this->project->environments()->first();
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function previewDeploymentsAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} is_preview_deployments_enabled', function () {
|
||||
test('can enable preview deployments', function () {
|
||||
$this->application->settings->update(['is_preview_deployments_enabled' => false]);
|
||||
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_preview_deployments_enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_preview_deployments_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('can disable preview deployments', function () {
|
||||
$this->application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_preview_deployments_enabled' => false,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_preview_deployments_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('is_preview_deployments_enabled is not changed when not in request', function () {
|
||||
$this->application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'name' => 'updated-name',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_preview_deployments_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects non-boolean value', function () {
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_preview_deployments_enabled' => 'yes',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/public is_preview_deployments_enabled', function () {
|
||||
test('can enable preview deployments on create', function () {
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-preview-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'is_preview_deployments_enabled' => true,
|
||||
'autogenerate_domain' => false,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
expect($application->settings->is_preview_deployments_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects non-boolean value on create', function () {
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-preview-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'is_preview_deployments_enabled' => 'yes',
|
||||
'autogenerate_domain' => false,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
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\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'public-git-api-test-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function createPublicGitApplication(array $overrides = []): Application
|
||||
{
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.test()->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/applications/public', array_merge([
|
||||
'project_uuid' => test()->project->uuid,
|
||||
'environment_uuid' => test()->environment->uuid,
|
||||
'server_uuid' => test()->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'static',
|
||||
'ports_exposes' => '80',
|
||||
'autogenerate_domain' => false,
|
||||
], $overrides));
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
return Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
}
|
||||
|
||||
test('public non github repositories keep their full git repository url', function () {
|
||||
$application = createPublicGitApplication([
|
||||
'git_repository' => 'https://cnb.cool/matridx/frp_webui',
|
||||
]);
|
||||
|
||||
expect($application->git_repository)->toBe('https://cnb.cool/matridx/frp_webui')
|
||||
->and($application->source_type)->toBeNull()
|
||||
->and($application->source_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('public github repositories are stored as owner and repository with the public github source', function () {
|
||||
GithubApp::unguarded(fn () => GithubApp::create([
|
||||
'id' => 0,
|
||||
'name' => 'Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'team_id' => 0,
|
||||
]));
|
||||
|
||||
$application = createPublicGitApplication([
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify-examples',
|
||||
]);
|
||||
|
||||
expect($application->git_repository)->toBe('coollabsio/coolify-examples')
|
||||
->and($application->source_type)->toBe(GithubApp::class)
|
||||
->and($application->source_id)->toBe(0);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ use Database\Seeders\StandaloneDockerSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -33,6 +34,11 @@ function seedRailpackExamplePrerequisites(): void
|
||||
]);
|
||||
}
|
||||
|
||||
function limaServers(): Collection
|
||||
{
|
||||
return collect(DevelopmentRailpackExamplesSeeder::LIMA_SERVERS);
|
||||
}
|
||||
|
||||
it('can seed the railpack examples directly on a clean development database', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
@@ -44,14 +50,51 @@ it('can seed the railpack examples directly on a clean development database', fu
|
||||
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()));
|
||||
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
|
||||
|
||||
$project = Project::query()
|
||||
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
|
||||
->first();
|
||||
|
||||
expect($project)
|
||||
->not->toBeNull()
|
||||
->and($project->environments)->toHaveCount(limaServers()->count());
|
||||
|
||||
foreach (limaServers() as $limaServer) {
|
||||
$server = Server::query()->where('uuid', $limaServer['server_uuid'])->first();
|
||||
|
||||
expect($server)->not->toBeNull();
|
||||
expect($server->settings->sentinel_custom_url)->toBe('http://host.lima.internal:8000');
|
||||
expect(StandaloneDocker::query()->whereRelation('server', 'uuid', $limaServer['server_uuid'])->exists())->toBeTrue();
|
||||
expect($project->environments()->where('uuid', $limaServer['environment_uuid'])->exists())->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
it('seeds the railpack examples in development mode', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
seedRailpackExamplePrerequisites();
|
||||
$legacyProject = Project::query()->create([
|
||||
'uuid' => 'railpack-examples-lima-ubuntu-2404',
|
||||
'name' => 'Railpack Examples - lima-ubuntu-2404',
|
||||
'description' => 'Legacy generated Railpack examples project',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
Application::query()->create([
|
||||
'uuid' => 'lima-ubuntu-2404-railpack-nextjs-ssr',
|
||||
'name' => 'Legacy Railpack Next.js SSR Example',
|
||||
'repository_project_id' => DevelopmentRailpackExamplesSeeder::REPOSITORY_PROJECT_ID,
|
||||
'git_repository' => DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY,
|
||||
'git_branch' => DevelopmentRailpackExamplesSeeder::GIT_BRANCH,
|
||||
'build_pack' => 'railpack',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $legacyProject->environments()->first()->id,
|
||||
'destination_id' => 0,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => 0,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
$project = Project::query()
|
||||
@@ -61,30 +104,58 @@ it('seeds the railpack examples in development mode', function () {
|
||||
expect($project)
|
||||
->not->toBeNull()
|
||||
->and($project->name)->toBe('Railpack Examples')
|
||||
->and($project->environments)->toHaveCount(1)
|
||||
->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID);
|
||||
->and($project->environments)->toHaveCount(limaServers()->count());
|
||||
expect(Project::query()->pluck('uuid')->sort()->values()->all())->toBe([
|
||||
'project',
|
||||
DevelopmentRailpackExamplesSeeder::PROJECT_UUID,
|
||||
]);
|
||||
expect(Application::query()->where('uuid', 'lima-ubuntu-2404-railpack-nextjs-ssr')->exists())->toBeFalse();
|
||||
|
||||
$applications = $project->applications()->with('settings')->orderBy('uuid')->get();
|
||||
|
||||
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
|
||||
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->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)
|
||||
fn (Application $application) => $application->git_repository === ($examples->get(str($application->uuid)->after('-')->value())['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
|
||||
))->toBeTrue();
|
||||
expect($applications->every(
|
||||
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
|
||||
fn (Application $application) => $application->git_branch === ($examples->get(str($application->uuid)->after('-')->value())['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
|
||||
))->toBeTrue();
|
||||
|
||||
$nestjs = $applications->firstWhere('uuid', 'railpack-nestjs');
|
||||
$angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static');
|
||||
$eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static');
|
||||
$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');
|
||||
foreach (limaServers() as $limaServer) {
|
||||
$limaEnvironment = $project->environments()
|
||||
->where('uuid', $limaServer['environment_uuid'])
|
||||
->first();
|
||||
|
||||
expect($limaEnvironment)
|
||||
->not->toBeNull()
|
||||
->and($limaEnvironment->name)->toBe($limaServer['environment_name']);
|
||||
|
||||
$limaApplications = $limaEnvironment->applications()->with('settings', 'destination.server')->orderBy('uuid')->get();
|
||||
|
||||
expect($limaApplications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
expect($limaApplications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
|
||||
expect($limaApplications->every(fn (Application $application) => str($application->uuid)->startsWith($limaServer['uuid_prefix'])))->toBeTrue();
|
||||
expect($limaApplications->every(fn (Application $application) => $application->destination->server->uuid === $limaServer['server_uuid']))->toBeTrue();
|
||||
expect($limaApplications->every(
|
||||
fn (Application $application) => $application->git_repository === ($examples->get(str($application->uuid)->after($limaServer['uuid_prefix'])->value())['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
|
||||
))->toBeTrue();
|
||||
expect($limaApplications->every(
|
||||
fn (Application $application) => $application->git_branch === ($examples->get(str($application->uuid)->after($limaServer['uuid_prefix'])->value())['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
|
||||
))->toBeTrue();
|
||||
}
|
||||
|
||||
$nestjs = $applications->firstWhere('uuid', 'ubuntu24-railpack-nestjs');
|
||||
$angularStatic = $applications->firstWhere('uuid', 'ubuntu24-railpack-angular-static');
|
||||
$eleventyStatic = $applications->firstWhere('uuid', 'ubuntu24-railpack-eleventy-static');
|
||||
$pythonFlask = $applications->firstWhere('uuid', 'ubuntu24-railpack-python-flask');
|
||||
$goGin = $applications->firstWhere('uuid', 'ubuntu24-railpack-go-gin');
|
||||
$rust = $applications->firstWhere('uuid', 'ubuntu24-railpack-rust');
|
||||
$githubDeployKey = $applications->firstWhere('uuid', 'ubuntu24-railpack-github-deploy-key');
|
||||
$gitlabDeployKey = $applications->firstWhere('uuid', 'ubuntu24-railpack-gitlab-deploy-key');
|
||||
$gitlabPublic = $applications->firstWhere('uuid', 'ubuntu24-railpack-gitlab-public-example');
|
||||
|
||||
expect($nestjs)
|
||||
->not->toBeNull()
|
||||
@@ -156,6 +227,12 @@ it('skips the railpack examples outside development mode', function () {
|
||||
|
||||
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse();
|
||||
expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse();
|
||||
|
||||
foreach (limaServers() as $limaServer) {
|
||||
expect(Project::query()->where('uuid', 'railpack-examples-lima-ubuntu-2404')->exists())->toBeFalse();
|
||||
expect(Project::query()->where('uuid', 'railpack-examples-lima-ubuntu-2604')->exists())->toBeFalse();
|
||||
expect(Application::query()->where('uuid', $limaServer['uuid_prefix'].'railpack-nextjs-ssr')->exists())->toBeFalse();
|
||||
}
|
||||
});
|
||||
|
||||
it('is idempotent when run multiple times', function () {
|
||||
@@ -170,5 +247,14 @@ it('is idempotent when run multiple times', function () {
|
||||
->first();
|
||||
|
||||
expect($project)->not->toBeNull();
|
||||
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
|
||||
|
||||
foreach (limaServers() as $limaServer) {
|
||||
$limaEnvironment = $project->environments()
|
||||
->where('uuid', $limaServer['environment_uuid'])
|
||||
->first();
|
||||
|
||||
expect($limaEnvironment)->not->toBeNull();
|
||||
expect($limaEnvironment->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
}
|
||||
});
|
||||
@@ -180,7 +180,7 @@ test('convertContainerEnvsToArray', function () {
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"RAY_ENABLED=true=123",
|
||||
"FEATURE_FLAG_WITH_EQUALS=true=123",
|
||||
"REGISTRY_URL=docker.io",
|
||||
"SUBSCRIPTION_PROVIDER=stripe",
|
||||
"TELESCOPE_ENABLED=false",
|
||||
@@ -295,5 +295,5 @@ test('convertContainerEnvsToArray', function () {
|
||||
}
|
||||
]';
|
||||
$envs = format_docker_envs_to_json($data);
|
||||
$this->assertEquals('true=123', $envs->get('RAY_ENABLED'));
|
||||
$this->assertEquals('true=123', $envs->get('FEATURE_FLAG_WITH_EQUALS'));
|
||||
});
|
||||
@@ -56,7 +56,7 @@ it('filters production environment variables by key case-insensitively', functio
|
||||
->toBe(['API_KEY']);
|
||||
});
|
||||
|
||||
it('treats production environment variable search wildcards literally', function () {
|
||||
it('treats production environment variable search underscore wildcards literally', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
@@ -75,23 +75,11 @@ it('treats production environment variable search wildcards literally', function
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'PERCENT%KEY',
|
||||
'value' => 'percent-secret',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(All::class, ['resource' => $application])
|
||||
->set('search', 'api_key');
|
||||
|
||||
expect($component->instance()->environmentVariables->pluck('key')->all())
|
||||
->toBe(['API_KEY']);
|
||||
|
||||
$component->set('search', '%KEY');
|
||||
|
||||
expect($component->instance()->environmentVariables->pluck('key')->all())
|
||||
->toBe(['PERCENT%KEY']);
|
||||
});
|
||||
|
||||
it('filters preview environment variables by key case-insensitively', function () {
|
||||
@@ -142,6 +130,34 @@ YAML,
|
||||
->toBe(['API_TOKEN']);
|
||||
});
|
||||
|
||||
it('searches service environment variables without requiring preview variables', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'secret',
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $service->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DATABASE_URL',
|
||||
'value' => 'postgres://example',
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $service->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(All::class, ['resource' => $service])
|
||||
->set('search', 'api')
|
||||
->assertSee('Production Environment Variables')
|
||||
->assertDontSee('Preview Deployments Environment Variables');
|
||||
|
||||
expect($component->instance()->environmentVariables->pluck('key')->all())
|
||||
->toBe(['API_KEY']);
|
||||
});
|
||||
|
||||
it('does not show the empty production message when search only matches hardcoded variables', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
@@ -155,11 +171,13 @@ services:
|
||||
YAML,
|
||||
]);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $service])
|
||||
$component = Livewire::test(All::class, ['resource' => $service])
|
||||
->set('search', 'api')
|
||||
->assertSee('Production Environment Variables')
|
||||
->assertSee('API_TOKEN')
|
||||
->assertDontSee('No environment variables found.');
|
||||
|
||||
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
|
||||
->toBe(['API_TOKEN']);
|
||||
});
|
||||
|
||||
it('keeps developer view unfiltered after searching', function () {
|
||||
@@ -242,10 +260,12 @@ it('hides the preview section when search filters out all preview variables', fu
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $application])
|
||||
$component = Livewire::test(All::class, ['resource' => $application])
|
||||
->set('search', 'api')
|
||||
->assertSee('Production Environment Variables')
|
||||
->assertSee('API_KEY')
|
||||
->assertDontSee('Preview Deployments Environment Variables')
|
||||
->assertDontSee('PREVIEW_TOKEN');
|
||||
|
||||
expect($component->instance()->environmentVariables->pluck('key')->all())
|
||||
->toBe(['API_KEY']);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('stores null environment variable values as database null', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'NULL_VALUE',
|
||||
'value' => null,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => 1,
|
||||
]);
|
||||
|
||||
$rawValue = DB::table('environment_variables')
|
||||
->where('id', $env->id)
|
||||
->value('value');
|
||||
|
||||
expect($rawValue)->toBeNull();
|
||||
|
||||
$env->refresh();
|
||||
expect($env->value)->toBeNull();
|
||||
});
|
||||
|
||||
it('preserves intentional empty string environment variable values', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'EMPTY_STRING_VALUE',
|
||||
'value' => '',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => 1,
|
||||
]);
|
||||
|
||||
$rawValue = DB::table('environment_variables')
|
||||
->where('id', $env->id)
|
||||
->value('value');
|
||||
|
||||
expect($rawValue)->not->toBeNull()
|
||||
->and(decrypt($rawValue))->toBe('');
|
||||
|
||||
$env->refresh();
|
||||
expect($env->value)->toBe('');
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const TWO_FILE_COMPOSE = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- ./wg_config.conf:/app/ps/wg0.conf
|
||||
- ./override_trays.json:/app/ps/override_tray.json
|
||||
YAML;
|
||||
|
||||
const DATA_DIR_COMPOSE = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
YAML;
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->destination = $destination;
|
||||
$this->environment = $environment;
|
||||
});
|
||||
|
||||
function makeComposeApplication(string $dockerComposeRaw): Application
|
||||
{
|
||||
return Application::factory()->create([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerComposeRaw,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Service, 1: ServiceApplication}
|
||||
*/
|
||||
function makeComposeService(string $dockerComposeRaw): array
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => test()->environment->id,
|
||||
'server_id' => test()->destination->server_id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'docker_compose_raw' => $dockerComposeRaw,
|
||||
]);
|
||||
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'name' => 'app',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
|
||||
return [$service, $serviceApplication];
|
||||
}
|
||||
|
||||
function seedFileVolume($resource, string $baseDir, string $fileName, string $mountPath, string $content): void
|
||||
{
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => "{$baseDir}/{$fileName}",
|
||||
'mount_path' => $mountPath,
|
||||
'content' => $content,
|
||||
'is_directory' => false,
|
||||
'resource_id' => $resource->id,
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('preserves existing application file volume content when reparsing compose bind mounts', function () {
|
||||
$application = makeComposeApplication(TWO_FILE_COMPOSE);
|
||||
$baseDir = application_configuration_dir()."/{$application->uuid}";
|
||||
|
||||
seedFileVolume($application, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
|
||||
seedFileVolume($application, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '0');
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$fileVolume = $application->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
|
||||
|
||||
expect($fileVolume->content)->toBe('0')
|
||||
->and($fileVolume->is_directory)->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps existing application file volumes as files when content is empty', function () {
|
||||
$application = makeComposeApplication(TWO_FILE_COMPOSE);
|
||||
$baseDir = application_configuration_dir()."/{$application->uuid}";
|
||||
|
||||
seedFileVolume($application, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
|
||||
seedFileVolume($application, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '');
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$fileVolume = $application->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
|
||||
|
||||
expect($fileVolume->content)->toBe('')
|
||||
->and($fileVolume->is_directory)->toBeFalse();
|
||||
});
|
||||
|
||||
it('defaults new application bind mounts to directories', function () {
|
||||
$application = makeComposeApplication(DATA_DIR_COMPOSE);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$fileVolume = $application->fileStorages()->where('mount_path', '/app/data')->first();
|
||||
|
||||
expect($fileVolume->content)->toBeNull()
|
||||
->and($fileVolume->is_directory)->toBeTrue();
|
||||
});
|
||||
|
||||
it('preserves existing service file volume content when reparsing compose bind mounts', function () {
|
||||
[$service, $serviceApplication] = makeComposeService(TWO_FILE_COMPOSE);
|
||||
$baseDir = service_configuration_dir()."/{$service->uuid}";
|
||||
|
||||
seedFileVolume($serviceApplication, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
|
||||
seedFileVolume($serviceApplication, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '0');
|
||||
|
||||
serviceParser($service);
|
||||
|
||||
$fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
|
||||
|
||||
expect($fileVolume->content)->toBe('0')
|
||||
->and($fileVolume->is_directory)->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps existing service file volumes as files when content is empty', function () {
|
||||
[$service, $serviceApplication] = makeComposeService(TWO_FILE_COMPOSE);
|
||||
$baseDir = service_configuration_dir()."/{$service->uuid}";
|
||||
|
||||
seedFileVolume($serviceApplication, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
|
||||
seedFileVolume($serviceApplication, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '');
|
||||
|
||||
serviceParser($service);
|
||||
|
||||
$fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
|
||||
|
||||
expect($fileVolume->content)->toBe('')
|
||||
->and($fileVolume->is_directory)->toBeFalse();
|
||||
});
|
||||
|
||||
it('defaults new service bind mounts to directories', function () {
|
||||
[$service, $serviceApplication] = makeComposeService(DATA_DIR_COMPOSE);
|
||||
|
||||
serviceParser($service);
|
||||
|
||||
$fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/data')->first();
|
||||
|
||||
expect($fileVolume->content)->toBeNull()
|
||||
->and($fileVolume->is_directory)->toBeTrue();
|
||||
});
|
||||
@@ -9,6 +9,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Once;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
@@ -77,6 +78,55 @@ it('accepts a valid magic link invitation only once and rotates the temporary pa
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
it('accepts a magic link when opened from a different public origin', function () {
|
||||
[$team, $user, $password, $token] = createInvitationLinkFixture();
|
||||
|
||||
$this->get('https://coolify.example.com/auth/link?token='.urlencode($token))
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertDatabaseMissing('team_invitations', ['email' => $user->email]);
|
||||
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check($password, $user->password))->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps the invited user authenticated after rotating the temporary password with database sessions', function () {
|
||||
$this->withMiddleware([CheckForcePasswordReset::class, DecideWhatToDoWithUser::class]);
|
||||
Config::set('session.driver', 'database');
|
||||
|
||||
[$team, $user, $password, $token] = createInvitationLinkFixture();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
expect(DB::table('sessions')->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
|
||||
$this->get(route('dashboard'))
|
||||
->assertRedirect(route('auth.force-password-reset'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check($password, $user->password))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a magic link when the stored invitation token differs', function () {
|
||||
[, $user, , $token, $invitation] = createInvitationLinkFixture();
|
||||
$differentToken = Crypt::encryptString("{$user->email}@@@{$invitation->uuid}@@@different-password");
|
||||
|
||||
$invitation->forceFill([
|
||||
'link' => route('auth.link', ['token' => $differentToken]),
|
||||
])->save();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
it('rejects a magic link when the invitation was revoked', function () {
|
||||
[, $user, , $token, $invitation] = createInvitationLinkFixture();
|
||||
$invitation->delete();
|
||||
|
||||
@@ -2,6 +2,40 @@
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
|
||||
it('uses safe domain validation rules in the application general form', function () {
|
||||
$component = new General;
|
||||
$method = new ReflectionMethod($component, 'rules');
|
||||
$rules = $method->invoke($component);
|
||||
|
||||
$validator = validator([
|
||||
'fqdn' => 'http://$(whoami).example.com',
|
||||
], [
|
||||
'fqdn' => $rules['fqdn'],
|
||||
]);
|
||||
|
||||
expect($validator->fails())->toBeTrue()
|
||||
->and($validator->errors()->has('fqdn'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('uses safe docker compose service domain validation rules in the application general form', function () {
|
||||
$component = new General;
|
||||
$method = new ReflectionMethod($component, 'rules');
|
||||
$rules = $method->invoke($component);
|
||||
|
||||
$validator = validator([
|
||||
'parsedServiceDomains' => [
|
||||
'app' => [
|
||||
'domain' => 'http://$(whoami).example.com',
|
||||
],
|
||||
],
|
||||
], [
|
||||
'parsedServiceDomains.*.domain' => $rules['parsedServiceDomains.*.domain'],
|
||||
]);
|
||||
|
||||
expect($validator->fails())->toBeTrue()
|
||||
->and($validator->errors()->has('parsedServiceDomains.app.domain'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('uses safe docker registry image validation rules in the application general form', function () {
|
||||
$component = new General;
|
||||
$method = new ReflectionMethod($component, 'rules');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Project;
|
||||
use Database\Seeders\PrivateKeySeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('seeds the first project with lima environments', function () {
|
||||
$this->seed([
|
||||
UserSeeder::class,
|
||||
TeamSeeder::class,
|
||||
PrivateKeySeeder::class,
|
||||
ProjectSeeder::class,
|
||||
]);
|
||||
|
||||
$project = Project::query()
|
||||
->where('uuid', 'project')
|
||||
->first();
|
||||
|
||||
expect($project)
|
||||
->not->toBeNull()
|
||||
->and($project->name)->toBe('My first project')
|
||||
->and($project->environments()->pluck('uuid', 'name')->all())->toBe([
|
||||
'ubuntu24' => 'ubuntu24',
|
||||
'ubuntu26' => 'ubuntu26',
|
||||
]);
|
||||
});
|
||||
@@ -218,7 +218,7 @@ describe('security UI audit logging', function () {
|
||||
|
||||
test('cloud provider token form does not contain debug ray calls', function () {
|
||||
expect(file_get_contents(app_path('Livewire/Security/CloudProviderTokenForm.php')))
|
||||
->not->toContain('ray(');
|
||||
->not->toContain('ray'.'(');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -130,6 +130,69 @@ describe('deployment job path field validation', function () {
|
||||
});
|
||||
|
||||
describe('API validation rules for path fields', function () {
|
||||
test('domains validation rejects command injection payloads', function (string $payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['domains' => $payload],
|
||||
['domains' => $rules['domains']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'host command substitution' => 'http://$(whoami).example.com',
|
||||
'path command substitution' => 'http://example.com/$(whoami)',
|
||||
'query command substitution' => 'http://example.com/path?next=$(id)',
|
||||
'host backtick substitution' => 'http://`whoami`.example.com',
|
||||
'path backtick substitution' => 'http://example.com/`whoami`',
|
||||
'semicolon command separator' => 'http://example.com/path;id',
|
||||
'newline injection' => "http://example.com\nwhoami",
|
||||
'carriage return injection' => "http://example.com\rwhoami",
|
||||
'pipe injection' => 'http://example.com/path|id',
|
||||
]);
|
||||
|
||||
test('domains validation rejects non http schemes', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['domains' => 'ftp://example.com'],
|
||||
['domains' => $rules['domains']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('domains validation allows comma separated http and https urls', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['domains' => 'https://app.example.com,http://api.example.com/path'],
|
||||
['domains' => $rules['domains']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('docker compose service domains validation rejects command injection payloads', function () {
|
||||
$rules = [
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||
];
|
||||
|
||||
$validator = validator(
|
||||
[
|
||||
'docker_compose_domains' => [
|
||||
['name' => 'app', 'domain' => 'https://app.example.com/$(whoami)'],
|
||||
],
|
||||
],
|
||||
$rules
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('git_branch validation rejects shell metacharacters', function (string $branch) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
@@ -276,7 +339,45 @@ describe('deployment git command escaping', function () {
|
||||
|
||||
expect($coolifyVariables->getValue($instance))
|
||||
->toContain("COOLIFY_BRANCH='main`id`' ")
|
||||
->toContain('COOLIFY_RESOURCE_UUID=app-uuid ');
|
||||
->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
|
||||
});
|
||||
|
||||
test('coolify url and fqdn shell assignments are quoted', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
$application = new Application;
|
||||
$application->uuid = 'app-uuid';
|
||||
$application->git_branch = 'main';
|
||||
$application->fqdn = 'https://app.example.com/path';
|
||||
$application->compose_parsing_version = '3';
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->include_source_commit_in_build = true;
|
||||
$application->setRelation('settings', $settings);
|
||||
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'commit' => 'HEAD$(id)',
|
||||
'pull_request_id' => 0,
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $job->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($instance, $value);
|
||||
}
|
||||
|
||||
$method = $job->getMethod('set_coolify_variables');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($instance);
|
||||
|
||||
$coolifyVariables = $job->getProperty('coolify_variables');
|
||||
$coolifyVariables->setAccessible(true);
|
||||
|
||||
expect($coolifyVariables->getValue($instance))
|
||||
->toContain("SOURCE_COMMIT='HEAD$(id)' ")
|
||||
->toContain("COOLIFY_URL='https://app.example.com/path' ")
|
||||
->toContain("COOLIFY_FQDN='app.example.com' ")
|
||||
->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ function authenticateGithubSetupCallbackTest(object $test): void
|
||||
session(['currentTeam' => $test->team]);
|
||||
}
|
||||
|
||||
function fakeGithubManifestConversion(): void
|
||||
function fakeGithubManifestConversion(string $apiUrl = 'https://api.github.com'): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
@@ -54,7 +54,7 @@ function fakeGithubManifestConversion(): void
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/app-manifests/*/conversions' => Http::response([
|
||||
"{$apiUrl}/app-manifests/*/conversions" => Http::response([
|
||||
'id' => 987654,
|
||||
'slug' => 'attacker-controlled-app',
|
||||
'client_id' => 'new-client-id',
|
||||
@@ -86,14 +86,14 @@ function configureGithubAppCredentials(GithubApp $githubApp): void
|
||||
])->save();
|
||||
}
|
||||
|
||||
function fakeGithubInstallationVerification(int $appId): void
|
||||
function fakeGithubInstallationVerification(int $appId, string $apiUrl = 'https://api.github.com'): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
"{$apiUrl}/zen" => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/*' => Http::response([
|
||||
"{$apiUrl}/app/installations/*" => Http::response([
|
||||
'id' => 555,
|
||||
'app_id' => $appId,
|
||||
], 200),
|
||||
@@ -183,6 +183,21 @@ it('configures an unbound github app with a valid one-time manifest state', func
|
||||
->and($this->githubApp->private_key_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('converts ghe dot com app manifests through the data residency api host', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
$this->githubApp->forceFill([
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
])->save();
|
||||
fakeGithubManifestConversion('https://api.octocorp.ghe.com');
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app-manifests/real-code/conversions');
|
||||
});
|
||||
|
||||
it('rejects replayed github app manifest states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
@@ -333,6 +348,23 @@ it('sets installation id when github confirms it belongs to the app', function (
|
||||
expect($this->githubApp->installation_id)->toBe(123456);
|
||||
});
|
||||
|
||||
it('verifies ghe dot com installations through the data residency api host', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
$this->githubApp->forceFill([
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
])->save();
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
fakeGithubInstallationVerification($this->githubApp->app_id, 'https://api.octocorp.ghe.com');
|
||||
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/zen');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app/installations/123456');
|
||||
});
|
||||
|
||||
it('rejects replayed github app install states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create(['show_boarding' => false]);
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the lima start command on lima server general pages in development', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
foreach (limaStartCommandDefinitions() as $definition) {
|
||||
$server = createServerForLimaStartCommandTest($definition['uuid'], $definition['port']);
|
||||
|
||||
$this->get(route('server.show', ['server_uuid' => $server->uuid]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Start this Lima VM locally')
|
||||
->assertSee($definition['command']);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not show the lima start command outside development', function () {
|
||||
config()->set('app.env', 'testing');
|
||||
|
||||
$server = createServerForLimaStartCommandTest('lima-ubuntu-2404', 2222);
|
||||
|
||||
$this->get(route('server.show', ['server_uuid' => $server->uuid]))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Start this Lima VM locally')
|
||||
->assertDontSee('limactl start --yes --name=coolify-lima-ubuntu-2404 docker/lima/ubuntu-2404.yaml');
|
||||
});
|
||||
|
||||
function createServerForLimaStartCommandTest(string $uuid, int $port): Server
|
||||
{
|
||||
return Server::factory()->create([
|
||||
'uuid' => $uuid,
|
||||
'name' => $uuid,
|
||||
'ip' => 'host.docker.internal',
|
||||
'port' => $port,
|
||||
'team_id' => test()->team->id,
|
||||
'private_key_id' => test()->privateKey->id,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function limaStartCommandDefinitions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'uuid' => 'lima-ubuntu-2404',
|
||||
'port' => 2222,
|
||||
'command' => 'limactl start --yes --name=coolify-lima-ubuntu-2404 docker/lima/ubuntu-2404.yaml',
|
||||
],
|
||||
[
|
||||
'uuid' => 'lima-ubuntu-2604',
|
||||
'port' => 2223,
|
||||
'command' => 'limactl start --yes --name=coolify-lima-ubuntu-2604 docker/lima/ubuntu-2604.yaml',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
});
|
||||
|
||||
function makeNavbarServer(bool $isFunctional): array
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$user->teams()->attach($team, ['role' => 'admin']);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'sentinel_updated_at' => now(),
|
||||
]);
|
||||
|
||||
$server->settings()->update([
|
||||
'is_reachable' => $isFunctional,
|
||||
'is_usable' => $isFunctional,
|
||||
'is_sentinel_enabled' => true,
|
||||
]);
|
||||
|
||||
test()->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return [$server->fresh(), $user, $team];
|
||||
}
|
||||
|
||||
it('does not show sentinel sync status before the server is validated', function () {
|
||||
[$server] = makeNavbarServer(isFunctional: false);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $server])
|
||||
->assertDontSee('Sentinel')
|
||||
->assertDontSee('In sync')
|
||||
->assertDontSee('Out of sync');
|
||||
});
|
||||
|
||||
it('shows sentinel sync status after the server is validated', function () {
|
||||
[$server] = makeNavbarServer(isFunctional: true);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $server])
|
||||
->assertSee('Sentinel')
|
||||
->assertSee('In sync');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use Database\Seeders\PrivateKeySeeder;
|
||||
use Database\Seeders\ServerSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function limaServerDefinitions(): array
|
||||
{
|
||||
return [
|
||||
['uuid' => 'lima-ubuntu-2404', 'port' => 2222, 'template' => 'ubuntu-2404.yaml', 'memory' => '2GiB', 'disk' => '20GiB'],
|
||||
['uuid' => 'lima-ubuntu-2604', 'port' => 2223, 'template' => 'ubuntu-2604.yaml', 'memory' => '2GiB', 'disk' => '20GiB'],
|
||||
];
|
||||
}
|
||||
|
||||
it('seeds the development testing host and lima servers', function () {
|
||||
$this->seed([
|
||||
UserSeeder::class,
|
||||
TeamSeeder::class,
|
||||
PrivateKeySeeder::class,
|
||||
ServerSeeder::class,
|
||||
]);
|
||||
|
||||
$testingHost = Server::query()->where('uuid', 'localhost')->first();
|
||||
|
||||
expect($testingHost)
|
||||
->not->toBeNull()
|
||||
->and($testingHost->ip)->toBe('coolify-testing-host');
|
||||
|
||||
foreach (limaServerDefinitions() as $definition) {
|
||||
$limaServer = Server::query()->where('uuid', $definition['uuid'])->first();
|
||||
|
||||
expect($limaServer)
|
||||
->not->toBeNull()
|
||||
->and($limaServer->name)->toBe($definition['uuid'])
|
||||
->and($limaServer->ip)->toBe('host.docker.internal')
|
||||
->and($limaServer->port)->toBe($definition['port'])
|
||||
->and($limaServer->user)->toBe('root')
|
||||
->and($limaServer->team_id)->toBe(0)
|
||||
->and($limaServer->private_key_id)->toBe(1)
|
||||
->and($limaServer->settings)->not->toBeNull()
|
||||
->and($limaServer->settings->sentinel_custom_url)->toBe('http://host.lima.internal:8000')
|
||||
->and($limaServer->destinations())->toHaveCount(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the lima templates aligned with the seeded servers', function () {
|
||||
foreach (limaServerDefinitions() as $definition) {
|
||||
$template = Yaml::parseFile(base_path("docker/lima/{$definition['template']}"));
|
||||
$script = data_get($template, 'provision.0.script');
|
||||
|
||||
expect(data_get($template, 'ssh.localPort'))
|
||||
->toBe($definition['port'])
|
||||
->and(data_get($template, 'memory'))->toBe($definition['memory'])
|
||||
->and(data_get($template, 'disk'))->toBe($definition['disk'])
|
||||
->and(data_get($template, 'containerd.system'))->toBeFalse()
|
||||
->and(data_get($template, 'containerd.user'))->toBeFalse()
|
||||
->and(data_get($template, 'networks'))->toBeNull()
|
||||
->and($script)->toContain('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68');
|
||||
}
|
||||
});
|
||||
@@ -87,3 +87,32 @@ it('renders the service templates last updated hint placeholder', function () {
|
||||
$view->assertSee('serviceTemplatesLastUpdated');
|
||||
$view->assertSee('service.templateLastUpdated');
|
||||
});
|
||||
|
||||
it('keeps service template keys for service selection and docs links', function () {
|
||||
$services = collect((new Select)->loadServices()['services']);
|
||||
$denoKv = $services->firstWhere('id', 'denoKV');
|
||||
|
||||
expect($denoKv)
|
||||
->not->toBeNull()
|
||||
->and($denoKv['docsSlug'])->toBe('denokv');
|
||||
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
$view = $this->view('livewire.project.new.select', [
|
||||
'current_step' => 'type',
|
||||
'environments' => collect(),
|
||||
]);
|
||||
|
||||
$view->assertSee("setType('one-click-service-' + service.id)", false);
|
||||
$view->assertSee('service.docsSlug || this.extractBaseServiceName(service.name)', false);
|
||||
});
|
||||
|
||||
it('preserves one click service key casing when selecting a service template', function () {
|
||||
$component = new Select;
|
||||
$component->servers = collect();
|
||||
$component->allServers = collect();
|
||||
|
||||
$component->setType('one-click-service-denoKV');
|
||||
|
||||
expect($component->type)->toBe('one-click-service-denoKV');
|
||||
});
|
||||
@@ -26,7 +26,7 @@ test('non-admin user is redirected from settings updates page', function () {
|
||||
test('instance admin can access settings updates page', function () {
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
@@ -39,3 +39,24 @@ test('instance admin can access settings updates page', function () {
|
||||
->assertOk()
|
||||
->assertNoRedirect();
|
||||
});
|
||||
|
||||
test('instance admin cannot save an invalid docker registry url', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
$settings = InstanceSettings::forceCreate(['id' => 0]);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->set('docker_registry_url', 'docker.io; touch /tmp/pwned')
|
||||
->call('instantSave')
|
||||
->assertHasErrors(['docker_registry_url' => 'in']);
|
||||
|
||||
expect($settings->fresh()->docker_registry_url)->toBe('docker.io');
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Team\InviteLink;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@@ -9,6 +11,8 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['fqdn' => null]));
|
||||
|
||||
// Create a team with owner, admin, and member
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
@@ -161,6 +165,47 @@ describe('privilege escalation prevention', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('new user invitation magic link uses instance fqdn when configured', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
));
|
||||
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(InviteLink::class)
|
||||
->set('email', 'fqdn-invitee@example.com')
|
||||
->set('role', 'member')
|
||||
->call('viaLink')
|
||||
->assertDispatched('success');
|
||||
|
||||
$invitation = TeamInvitation::whereEmail('fqdn-invitee@example.com')->firstOrFail();
|
||||
|
||||
expect($invitation->link)->toStartWith('https://coolify.example.com/auth/link?token=');
|
||||
});
|
||||
|
||||
test('new user invitation magic link falls back to route url when instance fqdn is not configured', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null]
|
||||
));
|
||||
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(InviteLink::class)
|
||||
->set('email', 'fallback-invitee@example.com')
|
||||
->set('role', 'member')
|
||||
->call('viaLink')
|
||||
->assertDispatched('success');
|
||||
|
||||
$invitation = TeamInvitation::whereEmail('fallback-invitee@example.com')->firstOrFail();
|
||||
|
||||
$expectedPrefix = route('auth.link', ['token' => '']);
|
||||
expect($invitation->link)->toStartWith($expectedPrefix);
|
||||
});
|
||||
|
||||
test('member cannot bypass policy by calling viaEmail', function () {
|
||||
// Login as member
|
||||
$this->actingAs($this->member);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
it('normalizes requested log line counts', function () {
|
||||
if (! function_exists('normalizeLogLines')) {
|
||||
expect(function_exists('normalizeLogLines'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
expect(normalizeLogLines(null))->toBe(100)
|
||||
->and(normalizeLogLines(''))->toBe(100)
|
||||
->and(normalizeLogLines('abc'))->toBe(100)
|
||||
->and(normalizeLogLines('0'))->toBe(100)
|
||||
->and(normalizeLogLines('-5'))->toBe(100)
|
||||
->and(normalizeLogLines('50'))->toBe(50)
|
||||
->and(normalizeLogLines('50000'))->toBe(10000);
|
||||
});
|
||||
|
||||
it('parses show_timestamps query values as booleans', function () {
|
||||
if (! function_exists('parseLogTimestampFlag')) {
|
||||
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
expect(parseLogTimestampFlag('true'))->toBeTrue()
|
||||
->and(parseLogTimestampFlag('1'))->toBeTrue()
|
||||
->and(parseLogTimestampFlag(true))->toBeTrue()
|
||||
->and(parseLogTimestampFlag('false'))->toBeFalse()
|
||||
->and(parseLogTimestampFlag('0'))->toBeFalse()
|
||||
->and(parseLogTimestampFlag(false))->toBeFalse()
|
||||
->and(parseLogTimestampFlag('not-a-bool'))->toBeFalse()
|
||||
->and(parseLogTimestampFlag(null))->toBeFalse();
|
||||
});
|
||||
|
||||
it('builds docker log commands with options before an escaped container id', function () {
|
||||
if (! function_exists('buildContainerLogsCommand')) {
|
||||
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server = new Server;
|
||||
$server->settings = ['is_swarm_manager' => false];
|
||||
|
||||
expect(buildContainerLogsCommand($server, 'container-1', 25, true))
|
||||
->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1");
|
||||
});
|
||||
|
||||
it('builds swarm service log commands with options before an escaped service id', function () {
|
||||
if (! function_exists('buildContainerLogsCommand')) {
|
||||
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server = new Server;
|
||||
$server->settings = ['is_swarm_manager' => true];
|
||||
|
||||
expect(buildContainerLogsCommand($server, "service'name", 25, true))
|
||||
->toBe("docker service logs -n 25 --timestamps 'service'\\''name' 2>&1");
|
||||
});
|
||||
|
||||
it('filters service sub containers in PHP instead of using user input in shell filters', function () {
|
||||
if (! function_exists('filterServiceSubContainersByName')) {
|
||||
expect(function_exists('filterServiceSubContainersByName'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$containers = collect([
|
||||
['ID' => 'first', 'Labels' => 'coolify.serviceId=10,coolify.name=app-service-uuid,coolify.type=service'],
|
||||
['ID' => 'second', 'Labels' => 'coolify.serviceId=10,coolify.name=db-service-uuid,coolify.type=service'],
|
||||
['ID' => 'third', 'Labels' => ['coolify.name' => 'app-service-uuid']],
|
||||
]);
|
||||
|
||||
expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all())
|
||||
->toBe(['first', 'third']);
|
||||
});
|
||||
|
||||
it('does not interpolate the requested service name into the docker ps shell command', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php');
|
||||
|
||||
expect($source)->not->toContain('coolify.name={$name}');
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
|
||||
function setDeploymentJobProperty(object $job, string $property, mixed $value): void
|
||||
{
|
||||
$reflectionProperty = new ReflectionProperty(ApplicationDeploymentJob::class, $property);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
function getDeploymentJobProperty(object $job, string $property): mixed
|
||||
{
|
||||
$reflectionProperty = new ReflectionProperty(ApplicationDeploymentJob::class, $property);
|
||||
|
||||
return $reflectionProperty->getValue($job);
|
||||
}
|
||||
|
||||
function invokeCheckGitIfBuildNeeded(object $job): void
|
||||
{
|
||||
$method = new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed');
|
||||
$method->invoke($job);
|
||||
}
|
||||
|
||||
function makeDeploymentJobForCommitCheck(string $pinnedSha, string $branchHeadSha): object
|
||||
{
|
||||
$job = new class extends ApplicationDeploymentJob
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function execute_remote_command(...$commands) {}
|
||||
};
|
||||
|
||||
$application = new class extends Application
|
||||
{
|
||||
public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
|
||||
{
|
||||
return [
|
||||
'commands' => collect([]),
|
||||
'branch' => 'main',
|
||||
'fullRepoUrl' => 'https://github.com/coollabsio/coolify.git',
|
||||
];
|
||||
}
|
||||
};
|
||||
$application->forceFill([
|
||||
'uuid' => 'application-uuid',
|
||||
'git_branch' => 'main',
|
||||
]);
|
||||
$application->setRelation('settings', (object) [
|
||||
'include_source_commit_in_build' => false,
|
||||
'use_build_secrets' => false,
|
||||
]);
|
||||
$application->setRelation('private_key', null);
|
||||
|
||||
$deploymentQueue = new class extends ApplicationDeploymentQueue
|
||||
{
|
||||
public bool $saved = false;
|
||||
|
||||
public function save(array $options = []): bool
|
||||
{
|
||||
$this->saved = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
$deploymentQueue->commit = $pinnedSha;
|
||||
|
||||
setDeploymentJobProperty($job, 'application', $application);
|
||||
setDeploymentJobProperty($job, 'application_deployment_queue', $deploymentQueue);
|
||||
setDeploymentJobProperty($job, 'deployment_uuid', 'deployment-uuid');
|
||||
setDeploymentJobProperty($job, 'pull_request_id', 0);
|
||||
setDeploymentJobProperty($job, 'commit', $pinnedSha);
|
||||
setDeploymentJobProperty($job, 'rollback', false);
|
||||
setDeploymentJobProperty($job, 'git_type', 'github');
|
||||
setDeploymentJobProperty($job, 'saved_outputs', collect([
|
||||
'git_commit_sha' => str("{$branchHeadSha}\trefs/heads/main"),
|
||||
]));
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
function shouldResolveBranchHeadForCommit(?string $commit): bool
|
||||
{
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
|
||||
$commitProperty = new ReflectionProperty($job, 'commit');
|
||||
$commitProperty->setValue($job, $commit ?? '');
|
||||
|
||||
$method = new ReflectionMethod($job, 'shouldResolveBranchHeadCommit');
|
||||
|
||||
return $method->invoke($job);
|
||||
}
|
||||
|
||||
describe('ApplicationDeploymentJob commit resolution', function () {
|
||||
test('resolves branch head for HEAD deployments', function () {
|
||||
expect(shouldResolveBranchHeadForCommit('HEAD'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('resolves branch head for blank deployments', function () {
|
||||
expect(shouldResolveBranchHeadForCommit(''))->toBeTrue();
|
||||
});
|
||||
|
||||
test('keeps pinned deployment commits instead of replacing them with branch head', function () {
|
||||
expect(shouldResolveBranchHeadForCommit('abc123def456abc123def456abc123def456abc1'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('check git does not overwrite pinned deployment commit with branch head', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$branchHeadSha = '111222333444555666777888999000aaabbbccc1';
|
||||
$job = makeDeploymentJobForCommitCheck($pinnedSha, $branchHeadSha);
|
||||
|
||||
invokeCheckGitIfBuildNeeded($job);
|
||||
|
||||
$deploymentQueue = getDeploymentJobProperty($job, 'application_deployment_queue');
|
||||
|
||||
expect(getDeploymentJobProperty($job, 'commit'))->toBe($pinnedSha)
|
||||
->and($deploymentQueue->commit)->toBe($pinnedSha)
|
||||
->and($deploymentQueue->saved)->toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,75 @@ it('fails fast when docker buildx is unavailable for railpack builds', function
|
||||
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
|
||||
});
|
||||
|
||||
it('checks buildx inside the helper container before railpack builds', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([], [
|
||||
'railpack_helper_buildx_available' => 'available',
|
||||
]);
|
||||
|
||||
invokeRailpackMethod($job, $reflection, 'ensure_helper_docker_buildx_available_for_railpack');
|
||||
|
||||
expect($job->recordedCommands[0][0][0])
|
||||
->toContain('DOCKER_CONFIG=/root/.docker docker buildx version');
|
||||
});
|
||||
|
||||
it('fails clearly when buildx is missing inside the helper container', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([], [
|
||||
'railpack_helper_buildx_available' => 'not-available',
|
||||
]);
|
||||
|
||||
expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_helper_docker_buildx_available_for_railpack'))
|
||||
->toThrow(DeploymentException::class, 'helper container');
|
||||
});
|
||||
|
||||
it('pins docker config while running railpack buildx commands', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([
|
||||
'uuid' => 'application-uuid',
|
||||
]);
|
||||
|
||||
$command = invokeRailpackMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'railpack_build_command',
|
||||
[
|
||||
'coollabsio/coolify:test',
|
||||
collect([]),
|
||||
],
|
||||
);
|
||||
|
||||
expect($command)
|
||||
->toContain('DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack')
|
||||
->toContain('DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack');
|
||||
});
|
||||
|
||||
it('filters reserved docker client variables from railpack build secrets', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([
|
||||
'uuid' => 'application-uuid',
|
||||
]);
|
||||
|
||||
$command = invokeRailpackMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'railpack_build_command',
|
||||
[
|
||||
'coollabsio/coolify:test',
|
||||
collect([
|
||||
'DOCKER_CONFIG' => '/tmp/no-buildx',
|
||||
'DOCKER_HOST' => 'tcp://invalid:2375',
|
||||
'RAILPACK_NODE_VERSION' => '22',
|
||||
'APP_ENV' => 'production',
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
expect($command)
|
||||
->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'")
|
||||
->toContain("--secret 'id=APP_ENV,env=APP_ENV'")
|
||||
->not->toContain('id=DOCKER_CONFIG')
|
||||
->not->toContain('id=DOCKER_HOST')
|
||||
->not->toContain("env 'DOCKER_CONFIG=")
|
||||
->not->toContain("env 'DOCKER_HOST=");
|
||||
});
|
||||
|
||||
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([
|
||||
'uuid' => 'application-uuid',
|
||||
|
||||
@@ -95,6 +95,6 @@ it('ensures docker_compose_raw update is wrapped in try-catch for error handling
|
||||
->toContain('// This keeps the original user input clean while preventing content reapplication')
|
||||
->toContain('try {')
|
||||
->toContain('$originalYaml = Yaml::parse($originalCompose);')
|
||||
->toContain('} catch (\Exception $e) {')
|
||||
->toContain("ray('Failed to update docker_compose_raw");
|
||||
->toContain('} catch (Exception) {')
|
||||
->toContain('// If parsing fails, keep the original docker_compose_raw unchanged');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('classifies github hosts', function () {
|
||||
expect(isGithubDotComHost('https://github.com'))->toBeTrue()
|
||||
->and(isGheDotComHost('https://octocorp.ghe.com'))->toBeTrue()
|
||||
->and(isGithubCloudFamilyHost('https://octocorp.ghe.com'))->toBeTrue()
|
||||
->and(isGithubCloudFamilyHost('https://github.com'))->toBeTrue()
|
||||
->and(isGithubEnterpriseServerHost('https://github.company.internal'))->toBeTrue()
|
||||
->and(isGithubEnterpriseServerHost('https://octocorp.ghe.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('derives github api urls from html urls', function (string $htmlUrl, string $apiUrl) {
|
||||
expect(githubApiUrlFromHtmlUrl($htmlUrl))->toBe($apiUrl);
|
||||
})->with([
|
||||
'github.com' => ['https://github.com', 'https://api.github.com'],
|
||||
'ghe.com data residency' => ['https://octocorp.ghe.com', 'https://api.octocorp.ghe.com'],
|
||||
'github enterprise server' => ['https://github.company.internal', 'https://github.company.internal/api/v3'],
|
||||
]);
|
||||
|
||||
it('rejects malformed urls when deriving an origin', function (string $url) {
|
||||
expect(fn () => githubUrlOrigin($url))->toThrow(InvalidArgumentException::class);
|
||||
})->with([
|
||||
'blank' => [''],
|
||||
'scheme-less' => ['github.company.internal'],
|
||||
'malformed' => ['not-a-url'],
|
||||
]);
|
||||
|
||||
it('generates correct install paths for github cloud ghe cloud and ghes', function (array $attributes, string $expectedPrefix) {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill(array_merge([
|
||||
'id' => 123,
|
||||
'name' => 'Coolify Test App',
|
||||
'team_id' => 456,
|
||||
], $attributes));
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||
$state = $query['state'] ?? null;
|
||||
|
||||
expect($installationUrl)->toStartWith($expectedPrefix)
|
||||
->and($state)->not->toBeEmpty()
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $state)))
|
||||
->toMatchArray([
|
||||
'action' => 'install',
|
||||
'github_app_id' => 123,
|
||||
'team_id' => 456,
|
||||
]);
|
||||
})->with([
|
||||
'github.com' => [
|
||||
['html_url' => 'https://github.com'],
|
||||
'https://github.com/apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
'ghe.com organization' => [
|
||||
['html_url' => 'https://octocorp.ghe.com', 'organization' => 'octo-corp'],
|
||||
'https://octocorp.ghe.com/apps/octo-corp/coolify-test-app/installations/new?',
|
||||
],
|
||||
'ghe.com blank organization fallback' => [
|
||||
['html_url' => 'https://octocorp.ghe.com', 'organization' => null],
|
||||
'https://octocorp.ghe.com/apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
'github enterprise server' => [
|
||||
['html_url' => 'https://github.company.internal', 'organization' => 'octo-corp'],
|
||||
'https://github.company.internal/github-apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
]);
|
||||
|
||||
it('encodes organization path segments in settings links', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'name' => 'coolify-app',
|
||||
'organization' => 'octo+corp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => 123,
|
||||
]);
|
||||
|
||||
expect(getPermissionsPath($githubApp))->toBe('https://octocorp.ghe.com/organizations/octo%2Bcorp/settings/apps/coolify-app/permissions');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
it('accepts hostnames containing underscores', function () {
|
||||
// Regression: PHP's FILTER_VALIDATE_URL rejects underscores in the host,
|
||||
// which blocked valid service domains (e.g. Docker service naming) from
|
||||
// being saved and getting Let's Encrypt certificates. See issue #10597.
|
||||
expect(isValidDomainUrl('https://myapp_service.example.com'))->toBeTrue();
|
||||
expect(isValidDomainUrl('http://my_app.example.com'))->toBeTrue();
|
||||
expect(isValidDomainUrl('https://a_b_c.example.com/path'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts ordinary domains and URLs', function () {
|
||||
expect(isValidDomainUrl('https://example.com'))->toBeTrue();
|
||||
expect(isValidDomainUrl('http://sub.example.com:8080/path?q=1'))->toBeTrue();
|
||||
expect(isValidDomainUrl('https://example.com/a_b'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects strings that are not valid URLs', function () {
|
||||
expect(isValidDomainUrl('not a url'))->toBeFalse();
|
||||
expect(isValidDomainUrl('example.com'))->toBeFalse();
|
||||
expect(isValidDomainUrl('ht_tp://example.com'))->toBeFalse();
|
||||
expect(isValidDomainUrl(''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects URLs that do not use HTTP or HTTPS schemes', function () {
|
||||
expect(isValidDomainUrl('ftp://example.com'))->toBeFalse();
|
||||
expect(isValidDomainUrl('javascript://example.com'))->toBeFalse();
|
||||
expect(isValidDomainUrl('data://example.com'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
function rayRemovalFiles(string $directory): array
|
||||
{
|
||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
|
||||
$files = [];
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'php') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
function rayRemovalBasePath(string $path = ''): string
|
||||
{
|
||||
return dirname(__DIR__, 2).($path === '' ? '' : DIRECTORY_SEPARATOR.$path);
|
||||
}
|
||||
|
||||
it('does not include Ray as a dependency', function () {
|
||||
$composer = json_decode(file_get_contents(rayRemovalBasePath('composer.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
$lock = json_decode(file_get_contents(rayRemovalBasePath('composer.lock')), true, flags: JSON_THROW_ON_ERROR);
|
||||
$lockedPackageNames = collect([
|
||||
...($lock['packages'] ?? []),
|
||||
...($lock['packages-dev'] ?? []),
|
||||
])->pluck('name');
|
||||
|
||||
expect($composer['require'] ?? [])->not->toHaveKey('spatie/laravel-ray')
|
||||
->and($composer['require-dev'] ?? [])->not->toHaveKey('spatie/laravel-ray')
|
||||
->and($lockedPackageNames)->not->toContain('spatie/laravel-ray', 'spatie/ray');
|
||||
});
|
||||
|
||||
it('does not ship Ray configuration', function () {
|
||||
expect(file_exists(rayRemovalBasePath('config/ray.php')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not call ray from application code', function () {
|
||||
$files = [
|
||||
...rayRemovalFiles(rayRemovalBasePath('app')),
|
||||
...rayRemovalFiles(rayRemovalBasePath('bootstrap/helpers')),
|
||||
];
|
||||
|
||||
$rayCalls = collect($files)
|
||||
->filter(fn (string $file): bool => preg_match('/\bray\s*\(/', file_get_contents($file)) === 1)
|
||||
->map(fn (string $file): string => str_replace(rayRemovalBasePath().'/', '', $file))
|
||||
->values();
|
||||
|
||||
expect($rayCalls)->toBeEmpty();
|
||||
});
|
||||
@@ -1,21 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Regression tests for SSRF via S3 Storage endpoint.
|
||||
* Regression tests for S3 Storage endpoint validation.
|
||||
*
|
||||
* The Livewire forms (Create.php, Form.php) and the model-level defense in
|
||||
* S3Storage::testConnection() share the same SafeWebhookUrl rule. These tests
|
||||
* assert the rule rejects the concrete payloads and that the model refuses to
|
||||
* build an S3 client for an unsafe endpoint.
|
||||
*/
|
||||
it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
|
||||
it('rejects disallowed targets on the S3 endpoint', function (string $endpoint) {
|
||||
$validator = Validator::make(
|
||||
['endpoint' => $endpoint],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
||||
@@ -43,19 +45,15 @@ it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
|
||||
it('accepts real-world S3 endpoints', function (string $endpoint) {
|
||||
$validator = Validator::make(
|
||||
['endpoint' => $endpoint],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34'])]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
|
||||
})->with([
|
||||
'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
|
||||
'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com',
|
||||
'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
|
||||
'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
|
||||
'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000',
|
||||
'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000',
|
||||
'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000',
|
||||
'Custom domain MinIO' => 'https://minio.example.com',
|
||||
'Custom public domain S3-compatible endpoint' => 'https://example.com',
|
||||
]);
|
||||
|
||||
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
|
||||
@@ -91,3 +89,18 @@ it('blocks testConnection() for loopback endpoints', function (string $endpoint)
|
||||
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
|
||||
'internal TLD' => 'http://backend.internal',
|
||||
]);
|
||||
|
||||
it('accepts explicitly allowlisted intranet S3 endpoints', function (string $endpoint, array $allowlist) {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
|
||||
|
||||
$validator = Validator::make(
|
||||
['endpoint' => $endpoint],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue("Expected allowlisted intranet S3 endpoint: {$endpoint}");
|
||||
})->with([
|
||||
'Self-hosted MinIO on 10.x CIDR' => ['http://10.0.0.5:9000', ['10.0.0.0/8']],
|
||||
'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']],
|
||||
'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']],
|
||||
]);
|
||||
@@ -1,13 +1,15 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('accepts valid public URLs', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
|
||||
|
||||
$validUrls = [
|
||||
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
|
||||
@@ -22,17 +24,6 @@ it('accepts valid public URLs', function () {
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts private network IPs for self-hosted deployments', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
|
||||
})->with([
|
||||
'10.x range' => 'http://10.0.0.5/webhook',
|
||||
'172.16.x range' => 'http://172.16.0.1:8080/hook',
|
||||
'192.168.x range' => 'http://192.168.1.50:8080/webhook',
|
||||
]);
|
||||
|
||||
it('rejects loopback addresses', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
@@ -117,3 +108,173 @@ it('rejects IPv6 loopback', function () {
|
||||
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
|
||||
});
|
||||
|
||||
it('rejects private and reserved network targets by default', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
|
||||
})->with([
|
||||
'private 10/8' => 'http://10.0.0.5/webhook',
|
||||
'private 172.16/12' => 'http://172.16.0.1:8080/hook',
|
||||
'private 192.168/16' => 'http://192.168.1.50:8080/webhook',
|
||||
'shared address space' => 'http://100.64.0.1/webhook',
|
||||
'zero network peer alias' => 'http://0.0.0.1/webhook',
|
||||
'multicast' => 'http://224.0.0.1/webhook',
|
||||
'benchmark range' => 'http://198.18.0.1/webhook',
|
||||
'documentation range' => 'http://192.0.2.10/webhook',
|
||||
]);
|
||||
|
||||
it('rejects hostname forms that resolve to loopback', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected loopback hostname-form rejection: {$url}");
|
||||
})->with([
|
||||
'decimal IPv4' => 'http://2130706433:8888/exfil',
|
||||
'hex IPv4' => 'http://0x7f000001:8888/exfil',
|
||||
'octal IPv4' => 'http://017700000001:8888/exfil',
|
||||
'short dotted IPv4' => 'http://127.1:8888/exfil',
|
||||
'IPv4-mapped IPv6 hex loopback' => 'http://[::ffff:7f00:1]:8888/exfil',
|
||||
]);
|
||||
|
||||
it('rejects internal DNS suffixes by default', function (string $url) {
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
|
||||
})->with([
|
||||
'.local host' => 'http://receiver.local/webhook',
|
||||
'.cluster.local host' => 'http://service.cluster.local/webhook',
|
||||
]);
|
||||
|
||||
it('rejects unresolvable hostnames by default', function () {
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => []);
|
||||
|
||||
$validator = Validator::make(['url' => 'http://does-not-resolve.example.test/webhook'], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host');
|
||||
});
|
||||
|
||||
it('keeps webhook DNS resolution enabled when general DNS validation is disabled', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['is_dns_validation_enabled' => false]));
|
||||
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => ['127.0.0.1']);
|
||||
|
||||
$validator = Validator::make(['url' => 'http://rebinding.example.test/webhook'], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue('Expected webhook SSRF DNS checks to remain enabled');
|
||||
});
|
||||
|
||||
it('reads configured custom DNS servers for webhook hostname resolution', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['custom_dns_servers' => '1.1.1.1, invalid, 2606:4700:4700::1111']));
|
||||
|
||||
$method = new ReflectionMethod(SafeWebhookUrl::class, 'customDnsServers');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke(new SafeWebhookUrl))
|
||||
->toBe(['1.1.1.1', '2606:4700:4700::1111']);
|
||||
});
|
||||
|
||||
it('allows explicitly configured intranet webhook targets', function (string $url, array $resolvedIps, array $allowlist) {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
|
||||
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->passes())->toBeTrue("Expected configured intranet target to pass: {$url}");
|
||||
})->with([
|
||||
'exact .local hostname' => ['http://receiver.local/webhook', ['192.168.10.20'], ['receiver.local']],
|
||||
'private CIDR' => ['http://hooks.example.test/webhook', ['10.50.10.20'], ['10.50.0.0/16']],
|
||||
]);
|
||||
|
||||
it('requires explicit localhost opt in in addition to allowlist', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => ['localhost']]));
|
||||
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue('Expected localhost to remain blocked without explicit localhost opt in');
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allow_localhost' => true]));
|
||||
|
||||
$validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
|
||||
|
||||
expect($validator->passes())->toBeTrue('Expected localhost to pass only after explicit localhost opt in');
|
||||
});
|
||||
|
||||
it('builds HTTP client options that pin resolved DNS for the request', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'webhook_allowed_internal_hosts' => ['localhost'],
|
||||
'webhook_allow_localhost' => true,
|
||||
]));
|
||||
|
||||
$options = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook');
|
||||
|
||||
expect($options['allow_redirects'])->toBeFalse();
|
||||
|
||||
if (defined('CURLOPT_RESOLVE')) {
|
||||
expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1');
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed while building HTTP options when the send-time resolution is unsafe', function () {
|
||||
expect(fn () => SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook'))
|
||||
->toThrow(RuntimeException::class, 'unsafe IP address');
|
||||
});
|
||||
|
||||
it('builds MinIO client resolve options for S3 backup uploads', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'webhook_allowed_internal_hosts' => ['localhost'],
|
||||
'webhook_allow_localhost' => true,
|
||||
]));
|
||||
|
||||
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
|
||||
|
||||
expect($options)->toContain('localhost:9000=127.0.0.1');
|
||||
});
|
||||
|
||||
it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
|
||||
|
||||
$validator = Validator::make(['url' => 'http://example.com./webhook'], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue('Expected trailing-dot hostname rejection');
|
||||
|
||||
expect(fn () => SafeWebhookUrl::httpClientOptions('http://example.com./webhook'))
|
||||
->toThrow(RuntimeException::class, 'trailing dot');
|
||||
});
|
||||
|
||||
it('rejects reserved IPv6 ranges by default', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected reserved IPv6 rejection: {$url}");
|
||||
})->with([
|
||||
'documentation IPv6' => 'http://[2001:db8::1]/webhook',
|
||||
'IPv4/IPv6 translation prefix' => 'http://[64:ff9b::1]/webhook',
|
||||
'6to4' => 'http://[2002::1]/webhook',
|
||||
]);
|
||||
|
||||
it('rejects hostnames that resolve to reserved IPv6 ranges by default', function (string $resolvedIp) {
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => [$resolvedIp]);
|
||||
|
||||
$validator = Validator::make(['url' => 'http://ipv6-reserved.example.test/webhook'], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected reserved IPv6 resolution rejection: {$resolvedIp}");
|
||||
})->with([
|
||||
'2001:db8::1',
|
||||
'64:ff9b::1',
|
||||
'2002::1',
|
||||
]);
|
||||
|
||||
it('redacts webhook URLs for logs', function () {
|
||||
expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar'))
|
||||
->toBe('https://hooks.slack.com');
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
use App\Models\Service;
|
||||
|
||||
it('does not treat service URLs with different path casing as duplicates', function () {
|
||||
$controller = new ServicesController;
|
||||
$method = new ReflectionMethod($controller, 'applyServiceUrls');
|
||||
$service = new class extends Service
|
||||
{
|
||||
public function applications()
|
||||
{
|
||||
return new class
|
||||
{
|
||||
public function where(string $column, mixed $value): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function first(): null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
$result = $method->invoke($controller, $service, [
|
||||
['name' => 'web', 'url' => 'https://example.com/Route'],
|
||||
['name' => 'api', 'url' => 'HTTPS://EXAMPLE.COM/route'],
|
||||
], '1');
|
||||
|
||||
expect($result['errors'] ?? [])->toBe([
|
||||
"Service container with 'web' not found.",
|
||||
"Service container with 'api' not found.",
|
||||
]);
|
||||
});
|
||||
@@ -1,21 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\UpdateCoolify;
|
||||
use App\Livewire\Settings\Updates;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function () {
|
||||
// Mock Server
|
||||
$this->mockServer = Mockery::mock(Server::class)->makePartial();
|
||||
$this->mockServer->id = 0;
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
// Mock InstanceSettings
|
||||
$this->settings = Mockery::mock(InstanceSettings::class);
|
||||
$this->settings->is_auto_update_enabled = true;
|
||||
$this->settings->shouldReceive('save')->andReturn(true);
|
||||
});
|
||||
function updateCoolifyTestCreateRootServerAndSettings(array $settings = []): void
|
||||
{
|
||||
Team::factory()->create(['id' => 0]);
|
||||
Server::forceCreate([
|
||||
'id' => 0,
|
||||
'name' => 'localhost',
|
||||
'ip' => '127.0.0.1',
|
||||
'user' => 'root',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
]);
|
||||
InstanceSettings::forceCreate(array_merge([
|
||||
'id' => 0,
|
||||
'is_auto_update_enabled' => true,
|
||||
'auto_update_frequency' => '0 0 * * *',
|
||||
'update_check_frequency' => '0 * * * *',
|
||||
], $settings));
|
||||
Once::flush();
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
@@ -26,15 +48,7 @@ it('has UpdateCoolify action class', function () {
|
||||
});
|
||||
|
||||
it('validates cache against running version before fallback', function () {
|
||||
// Mock Server::find to return our mock server
|
||||
Server::shouldReceive('find')
|
||||
->with(0)
|
||||
->andReturn($this->mockServer);
|
||||
|
||||
// Mock instanceSettings
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
updateCoolifyTestCreateRootServerAndSettings();
|
||||
|
||||
// CDN fails
|
||||
Http::fake(['*' => Http::response(null, 500)]);
|
||||
@@ -51,7 +65,7 @@ it('validates cache against running version before fallback', function () {
|
||||
try {
|
||||
$action->handle(manual_update: false);
|
||||
expect(false)->toBeTrue('Expected exception was not thrown');
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
expect($e->getMessage())->toContain('cache version');
|
||||
expect($e->getMessage())->toContain('4.0.5');
|
||||
expect($e->getMessage())->toContain('4.0.10');
|
||||
@@ -59,15 +73,9 @@ it('validates cache against running version before fallback', function () {
|
||||
});
|
||||
|
||||
it('uses validated cache when CDN fails and cache is newer', function () {
|
||||
// Mock Server::find
|
||||
Server::shouldReceive('find')
|
||||
->with(0)
|
||||
->andReturn($this->mockServer);
|
||||
|
||||
// Mock instanceSettings
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
updateCoolifyTestCreateRootServerAndSettings();
|
||||
Queue::fake();
|
||||
config(['constants.ssh.mux_enabled' => false]);
|
||||
|
||||
// CDN fails
|
||||
Http::fake(['*' => Http::response(null, 500)]);
|
||||
@@ -78,12 +86,9 @@ it('uses validated cache when CDN fails and cache is newer', function () {
|
||||
|
||||
config(['constants.coolify.version' => '4.0.5']);
|
||||
|
||||
// Mock the update method to prevent actual update
|
||||
$action = Mockery::mock(UpdateCoolify::class)->makePartial();
|
||||
$action->shouldReceive('update')->once();
|
||||
$action->server = $this->mockServer;
|
||||
$action = new UpdateCoolify;
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('warning')
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('Failed to fetch fresh version from CDN, using validated cache', Mockery::type('array'));
|
||||
|
||||
@@ -93,16 +98,198 @@ it('uses validated cache when CDN fails and cache is newer', function () {
|
||||
expect($action->latestVersion)->toBe('4.0.10');
|
||||
});
|
||||
|
||||
it('prevents downgrade even with manual update', function () {
|
||||
// Mock Server::find
|
||||
Server::shouldReceive('find')
|
||||
->with(0)
|
||||
->andReturn($this->mockServer);
|
||||
it('passes the saved registry URL to the upgrade script command', function () {
|
||||
Queue::fake();
|
||||
config([
|
||||
'app.env' => 'testing',
|
||||
'constants.coolify.version' => '4.0.9',
|
||||
'constants.coolify.helper_version' => '1.0.14',
|
||||
'constants.coolify.upgrade_script_url' => 'https://cdn.example.com/upgrade.sh',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
|
||||
// Mock instanceSettings
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
updateCoolifyTestCreateRootServerAndSettings([
|
||||
'is_auto_update_enabled' => true,
|
||||
'docker_registry_url' => 'ghcr.io',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'coolify' => ['v4' => ['version' => '4.0.10']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
(new UpdateCoolify)->handle();
|
||||
|
||||
expect(Activity::query()->latest('id')->first()?->getExtraProperty('command'))->toBe(
|
||||
"curl -fsSL https://cdn.example.com/upgrade.sh -o /data/coolify/source/upgrade.sh\n".
|
||||
"bash /data/coolify/source/upgrade.sh '4.0.10' '1.0.14' 'ghcr.io'"
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to docker io for the upgrade script command when no registry is saved', function () {
|
||||
Queue::fake();
|
||||
config([
|
||||
'app.env' => 'testing',
|
||||
'constants.coolify.version' => '4.0.9',
|
||||
'constants.coolify.helper_version' => '1.0.14',
|
||||
'constants.coolify.registry_url' => 'ghcr.io',
|
||||
'constants.coolify.upgrade_script_url' => 'https://cdn.example.com/upgrade.sh',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
|
||||
updateCoolifyTestCreateRootServerAndSettings([
|
||||
'is_auto_update_enabled' => true,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'coolify' => ['v4' => ['version' => '4.0.10']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
(new UpdateCoolify)->handle();
|
||||
|
||||
expect(Activity::query()->latest('id')->first()?->getExtraProperty('command'))->toBe(
|
||||
"curl -fsSL https://cdn.example.com/upgrade.sh -o /data/coolify/source/upgrade.sh\n".
|
||||
"bash /data/coolify/source/upgrade.sh '4.0.10' '1.0.14' 'docker.io'"
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults the registry setting to docker io when no registry is saved', function () {
|
||||
config([
|
||||
'app.env' => 'testing',
|
||||
'constants.coolify.registry_url' => 'ghcr.io',
|
||||
'constants.coolify.self_hosted' => true,
|
||||
]);
|
||||
|
||||
updateCoolifyTestCreateRootServerAndSettings();
|
||||
|
||||
$rootTeam = Team::findOrFail(0);
|
||||
$user = User::factory()->create();
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->assertSet('docker_registry_url', 'docker.io');
|
||||
});
|
||||
|
||||
it('uses the database registry for helper images when the configured helper image is default', function () {
|
||||
config([
|
||||
'constants.coolify.registry_url' => 'ghcr.io',
|
||||
'constants.coolify.helper_image' => 'ghcr.io/coollabsio/coolify-helper',
|
||||
]);
|
||||
|
||||
updateCoolifyTestCreateRootServerAndSettings([
|
||||
'docker_registry_url' => 'docker.io',
|
||||
]);
|
||||
|
||||
expect(coolifyRegistryUrl())->toBe('docker.io')
|
||||
->and(coolifyHelperImage())->toBe('docker.io/coollabsio/coolify-helper');
|
||||
});
|
||||
|
||||
it('preserves an explicit custom helper image override', function () {
|
||||
config([
|
||||
'constants.coolify.registry_url' => 'docker.io',
|
||||
'constants.coolify.helper_image' => 'registry.example.com/custom/helper',
|
||||
]);
|
||||
|
||||
updateCoolifyTestCreateRootServerAndSettings([
|
||||
'docker_registry_url' => 'ghcr.io',
|
||||
]);
|
||||
|
||||
expect(coolifyHelperImage())->toBe('registry.example.com/custom/helper');
|
||||
});
|
||||
|
||||
it('uses the database registry for sentinel images', function () {
|
||||
$action = file_get_contents(app_path('Actions/Server/StartSentinel.php'));
|
||||
|
||||
expect($action)->toContain("\$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.\$version;");
|
||||
});
|
||||
|
||||
it('rejects invalid registry values and does not sync them', function () {
|
||||
Process::fake();
|
||||
config([
|
||||
'app.env' => 'testing',
|
||||
'constants.coolify.registry_url' => 'docker.io',
|
||||
]);
|
||||
|
||||
updateCoolifyTestCreateRootServerAndSettings([
|
||||
'is_auto_update_enabled' => true,
|
||||
'auto_update_frequency' => '0 0 * * *',
|
||||
'update_check_frequency' => '0 * * * *',
|
||||
'docker_registry_url' => 'docker.io',
|
||||
]);
|
||||
|
||||
$rootTeam = Team::findOrFail(0);
|
||||
$user = User::factory()->create();
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->set('docker_registry_url', 'ghcr.io; touch /tmp/pwned')
|
||||
->call('submit')
|
||||
->assertHasErrors(['docker_registry_url' => ['in']]);
|
||||
|
||||
expect(InstanceSettings::findOrFail(0)->docker_registry_url)->toBe('docker.io');
|
||||
Process::assertDidntRun(fn () => true);
|
||||
});
|
||||
|
||||
it('does not save registry changes when syncing the env file fails', function () {
|
||||
config([
|
||||
'app.env' => 'testing',
|
||||
'constants.coolify.registry_url' => 'docker.io',
|
||||
'constants.coolify.self_hosted' => true,
|
||||
]);
|
||||
|
||||
updateCoolifyTestCreateRootServerAndSettings([
|
||||
'is_auto_update_enabled' => true,
|
||||
'auto_update_frequency' => '0 0 * * *',
|
||||
'update_check_frequency' => '0 * * * *',
|
||||
'docker_registry_url' => 'docker.io',
|
||||
]);
|
||||
|
||||
$rootTeam = Team::findOrFail(0);
|
||||
$user = User::factory()->create();
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
$component = new class extends Updates
|
||||
{
|
||||
protected function syncRegistryUrlToEnv(string $registryUrl): void
|
||||
{
|
||||
throw new RuntimeException('sync failed');
|
||||
}
|
||||
};
|
||||
$component->settings = InstanceSettings::findOrFail(0);
|
||||
$component->auto_update_frequency = '0 0 * * *';
|
||||
$component->update_check_frequency = '0 * * * *';
|
||||
$component->is_auto_update_enabled = true;
|
||||
$component->docker_registry_url = 'ghcr.io';
|
||||
|
||||
$component->instantSave();
|
||||
|
||||
expect(InstanceSettings::findOrFail(0)->docker_registry_url)->toBe('docker.io');
|
||||
});
|
||||
|
||||
it('appends registry url to env file when the key is missing', function () {
|
||||
$component = new Updates;
|
||||
$method = new ReflectionMethod(Updates::class, 'registryEnvSyncCommand');
|
||||
|
||||
expect($method->invoke($component, 'ghcr.io'))
|
||||
->toContain("grep -q '^REGISTRY_URL=' /data/coolify/source/.env")
|
||||
->toContain("sed -i 's|^REGISTRY_URL=.*|REGISTRY_URL=ghcr.io|' /data/coolify/source/.env")
|
||||
->toContain("printf '%s\\n' 'REGISTRY_URL=ghcr.io' >> /data/coolify/source/.env");
|
||||
});
|
||||
|
||||
it('prevents downgrade even with manual update', function () {
|
||||
updateCoolifyTestCreateRootServerAndSettings();
|
||||
|
||||
// CDN returns older version
|
||||
Http::fake([
|
||||
@@ -116,7 +303,7 @@ it('prevents downgrade even with manual update', function () {
|
||||
|
||||
$action = new UpdateCoolify;
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('error')
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with('Downgrade prevented', Mockery::type('array'));
|
||||
|
||||
@@ -124,7 +311,7 @@ it('prevents downgrade even with manual update', function () {
|
||||
try {
|
||||
$action->handle(manual_update: true);
|
||||
expect(false)->toBeTrue('Expected exception was not thrown');
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
expect($e->getMessage())->toContain('Cannot downgrade');
|
||||
expect($e->getMessage())->toContain('4.0.10');
|
||||
expect($e->getMessage())->toContain('4.0.0');
|
||||
|
||||
@@ -44,6 +44,38 @@ it('downloads postgres upgrade script during install and upgrade without auto-ru
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('uses the selected registry url when extracting upgrade images', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($script)->toContain('IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images');
|
||||
})->with([
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('persists the selected registry url during upgrades', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($script)->toContain('set_env_var "REGISTRY_URL" "$REGISTRY_URL"');
|
||||
})->with([
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('uses the existing env registry url when old callers do not pass a registry argument', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($script)
|
||||
->toContain('if [ -n "${3+x}" ]; then')
|
||||
->toContain('REGISTRY_URL="$3"')
|
||||
->toContain('elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then')
|
||||
->toContain("REGISTRY_URL=$(grep \"^REGISTRY_URL=\" \"\$ENV_FILE\" | cut -d '=' -f2- | head -n1)")
|
||||
->toContain('REGISTRY_URL="docker.io"');
|
||||
})->with([
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
|
||||
@@ -180,3 +180,14 @@ it('normalizes environment variable keys before model validation', function () {
|
||||
|
||||
expect($environmentVariable->key)->toBe('APP_ENV');
|
||||
});
|
||||
|
||||
it('normalizes application domain scheme and host without lowercasing path query or fragment', function () {
|
||||
$domains = ' HTTPS://EXAMPLE.COM/MixedCase/Path?Token=ABC#Fragment, http://Sub.EXAMPLE.com/Api/V1 ';
|
||||
|
||||
expect(ValidationPatterns::normalizeApplicationDomains($domains))
|
||||
->toBe('https://example.com/MixedCase/Path?Token=ABC#Fragment,http://sub.example.com/Api/V1');
|
||||
});
|
||||
|
||||
it('validates application domains with underscores in the hostname', function () {
|
||||
expect(ValidationPatterns::validateApplicationDomains('https://myapp_service.example.com'))->toBeEmpty();
|
||||
});
|
||||
Reference in new issue
Block a user