mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 04:18:34 +00:00
Merge remote-tracking branch 'origin/next' into 2731-investigate-failed-git-clone
This commit is contained in:
commit
bc8928fdc4
583 files changed
+30566
-11158
No files matched your search
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Index as AdminIndex;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -70,9 +71,9 @@ test('submitSearch requires admin authorization', function () {
|
||||
test('switchUser requires root user id 0', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = Team::factory()->create();
|
||||
@@ -84,7 +85,47 @@ test('switchUser requires root user id 0', function () {
|
||||
Livewire::test(AdminIndex::class)
|
||||
->assertOk()
|
||||
->call('switchUser', $targetUser->id)
|
||||
->assertRedirect();
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
test('back() redirects impersonator to admin index and clears session', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session([
|
||||
'currentTeam' => ['id' => $rootTeam->id],
|
||||
'impersonating' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->call('back')
|
||||
->assertRedirect(route('admin.index'));
|
||||
|
||||
expect(session('impersonating'))->toBeNull();
|
||||
});
|
||||
|
||||
test('switchUser ignores Referer header and uses dashboard route', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere'])
|
||||
->test(AdminIndex::class)
|
||||
->call('switchUser', $targetUser->id)
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
test('switchUser rejects non-root user', function () {
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
<?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' => 'railpack-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 railpackApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function makeRailpackApp(array $overrides = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'build_pack' => 'railpack',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () {
|
||||
test('rejects unsupported build_pack at controller layer', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}", [
|
||||
'build_pack' => 'totally-bogus',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('switching from dockerfile to railpack clears dockerfile fields', function () {
|
||||
$app = makeRailpackApp([
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM node:20',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
'dockerfile_target_build' => 'production',
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}", [
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->build_pack)->toBe('railpack');
|
||||
expect($app->dockerfile)->toBeNull();
|
||||
expect($app->dockerfile_location)->toBeNull();
|
||||
expect($app->dockerfile_target_build)->toBeNull();
|
||||
expect((bool) $app->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('switching from dockercompose to railpack clears compose fields and SERVICE_* envs', function () {
|
||||
$app = makeRailpackApp([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => '{"app": "example.com"}',
|
||||
'docker_compose_raw' => "version: '3'\nservices:\n app:\n image: nginx",
|
||||
]);
|
||||
|
||||
$app->environment_variables()->createMany([
|
||||
['key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', 'is_buildtime' => false, 'is_preview' => false],
|
||||
['key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', 'is_buildtime' => false, 'is_preview' => false],
|
||||
['key' => 'REGULAR_VAR', 'value' => 'keep_me', 'is_buildtime' => false, 'is_preview' => false],
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}", [
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->build_pack)->toBe('railpack');
|
||||
expect($app->docker_compose_domains)->toBeNull();
|
||||
expect($app->docker_compose_raw)->toBeNull();
|
||||
expect($app->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
|
||||
expect($app->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
|
||||
expect($app->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('install/build/start commands persist for railpack apps', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}", [
|
||||
'install_command' => 'npm ci',
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->install_command)->toBe('npm ci');
|
||||
expect($app->build_command)->toBe('npm run build');
|
||||
expect($app->start_command)->toBe('node server.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/envs RAILPACK_* handling', function () {
|
||||
test('adding RAILPACK_NODE_VERSION via API surfaces in railpack_environment_variables only', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->railpack_environment_variables)->toHaveCount(1);
|
||||
expect($app->railpack_environment_variables->first()->key)->toBe('RAILPACK_NODE_VERSION');
|
||||
expect($app->runtime_environment_variables->where('key', 'RAILPACK_NODE_VERSION'))->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('runtime envs added via API surface in runtime_environment_variables but not railpack_*', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '18',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$runtime = $app->runtime_environment_variables;
|
||||
expect($runtime->pluck('key')->all())->toBe(['APP_ENV']);
|
||||
expect($app->railpack_environment_variables)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('preview RAILPACK_* envs surface in railpack_environment_variables_preview only', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'RAILPACK_BUILD_CMD',
|
||||
'value' => 'npm run build',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => true,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->railpack_environment_variables_preview)->toHaveCount(1);
|
||||
expect($app->railpack_environment_variables)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('buildtime-only env has is_buildtime=true and is_runtime=false', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'sekret',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'API_KEY')->first();
|
||||
expect($env)->not->toBeNull();
|
||||
expect((bool) $env->is_buildtime)->toBeTrue();
|
||||
expect((bool) $env->is_runtime)->toBeFalse();
|
||||
// Buildtime-only non-RAILPACK_ var: visible to runtime relation (it's not a buildpack-control var)
|
||||
// but is_runtime flag is false; consumers gate runtime via is_runtime, not via the relation alone.
|
||||
expect($env->resourceable_id)->toBe($app->id);
|
||||
});
|
||||
|
||||
test('runtime-only env has is_runtime=true and is_buildtime=false', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'LOG_LEVEL',
|
||||
'value' => 'debug',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'LOG_LEVEL')->first();
|
||||
expect((bool) $env->is_buildtime)->toBeFalse();
|
||||
expect((bool) $env->is_runtime)->toBeTrue();
|
||||
});
|
||||
|
||||
test('railpack build variables collection includes only is_buildtime=true entries', function () {
|
||||
// Sanity check the underlying query used by the deploy job: railpack_build_variables()
|
||||
// pulls $application->environment_variables()->where('is_buildtime', true)->get()
|
||||
// (see ApplicationDeploymentJob::railpack_build_variables).
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'BUILD_ARG',
|
||||
'value' => 'in-build',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'RUNTIME_ARG',
|
||||
'value' => 'in-runtime',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$buildtime = $app->environment_variables()->where('is_buildtime', true)->pluck('key')->all();
|
||||
expect($buildtime)->toContain('BUILD_ARG');
|
||||
expect($buildtime)->not->toContain('RUNTIME_ARG');
|
||||
});
|
||||
|
||||
test('user-defined COOLIFY_FQDN takes precedence over auto-generated', function () {
|
||||
// Documents generate_coolify_env_variables() override behavior:
|
||||
// it skips generation when application->environment_variables already has the key.
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'COOLIFY_FQDN',
|
||||
'value' => 'overridden.example.com',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'COOLIFY_FQDN')->first();
|
||||
expect($env)->not->toBeNull();
|
||||
expect($env->value)->toBe('overridden.example.com');
|
||||
// Confirm the model relation used by override-skip logic finds it
|
||||
expect($app->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty())->toBeFalse();
|
||||
});
|
||||
|
||||
test('is_literal flag persists on create', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'RAILPACK_LITERAL_FLAG',
|
||||
'value' => '$NOT_INTERPOLATED',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
'is_literal' => true,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'RAILPACK_LITERAL_FLAG')->first();
|
||||
expect((bool) $env->is_literal)->toBeTrue();
|
||||
});
|
||||
|
||||
test('PATCH env updates buildtime/runtime flags', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'TOGGLE_VAR',
|
||||
'value' => 'v1',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'TOGGLE_VAR',
|
||||
'value' => 'v2',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_multiline' => false,
|
||||
'is_shown_once' => false,
|
||||
])->assertStatus(201);
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'TOGGLE_VAR')->first();
|
||||
expect($env->value)->toBe('v2');
|
||||
expect((bool) $env->is_buildtime)->toBeFalse();
|
||||
expect((bool) $env->is_runtime)->toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
describe('token creation with expiration', function () {
|
||||
test('livewire component stores expires_at when expiresInDays set', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'test-token')
|
||||
->set('expiresInDays', 7)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $this->user->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->expires_at)->not->toBeNull()
|
||||
->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
|
||||
->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'never-token')
|
||||
->set('expiresInDays', null)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $this->user->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->expires_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('livewire component rejects invalid expiresInDays value', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'bad-token')
|
||||
->set('expiresInDays', 42)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasErrors('expiresInDays');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired token rejected on API', function () {
|
||||
test('request with expired token returns 401', function () {
|
||||
$token = $this->user->createToken('expired', ['read'], now()->subDay());
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('request with non-expired token works', function () {
|
||||
$token = $this->user->createToken('valid', ['read'], now()->addDay());
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApiTokenExpirationWarningJob;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ApiTokenExpiringNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Notifications\Dispatcher;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]);
|
||||
$this->team->discordNotificationSettings()->update([
|
||||
'discord_enabled' => true,
|
||||
'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake',
|
||||
]);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Cache::flush();
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon $warningSentAt = null): PersonalAccessToken
|
||||
{
|
||||
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
|
||||
$token = $plain->accessToken;
|
||||
$token->team_id = $team->id;
|
||||
$token->api_token_expiration_warning_sent_at = $warningSentAt;
|
||||
$token->save();
|
||||
|
||||
return $token->fresh();
|
||||
}
|
||||
|
||||
describe('ApiTokenExpirationWarningJob', function () {
|
||||
test('notifies team when token expires within 24h', function () {
|
||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
|
||||
expect($token->fresh()->api_token_expiration_warning_sent_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('does not mark token as warned when notification fails', function () {
|
||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
$dispatcher = Mockery::mock(Dispatcher::class);
|
||||
$dispatcher->shouldReceive('send')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Notification failed'));
|
||||
|
||||
$this->app->instance(Dispatcher::class, $dispatcher);
|
||||
|
||||
expect(fn () => (new ApiTokenExpirationWarningJob)->handle())
|
||||
->toThrow(RuntimeException::class, 'Notification failed');
|
||||
|
||||
expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('database marker prevents duplicate warnings on repeat runs', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('database marker prevents duplicate warnings after cache is flushed', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('skips tokens that already have an expiration warning marker', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('notifies once for each unmarked expiring token', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);
|
||||
});
|
||||
|
||||
test('skips tokens expiring more than 24h out', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('skips already-expired tokens', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->subHour());
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('skips tokens with null expires_at', function () {
|
||||
createTokenExpiring($this->user, $this->team, null);
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
});
|
||||
|
||||
test('api token permission flags are locked', function (string $property) {
|
||||
$property = new ReflectionProperty(ApiTokens::class, $property);
|
||||
|
||||
expect($property->getAttributes(Locked::class))->not->toBeEmpty();
|
||||
})->with([
|
||||
'root permission flag' => 'canUseRootPermissions',
|
||||
'write permission flag' => 'canUseWritePermissions',
|
||||
]);
|
||||
|
||||
test('member cannot tamper with root permission flag', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('canUseRootPermissions', true);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
|
||||
test('member cannot create root token through tampered permissions payload', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'pwned-root-token')
|
||||
->set('expiresInDays', 30)
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken');
|
||||
|
||||
expect($member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member can still create read token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'read-token')
|
||||
->set('expiresInDays', 30)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $member->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->abilities)->toBe(['read']);
|
||||
});
|
||||
|
||||
test('owner can create root token', function () {
|
||||
$owner = User::factory()->create();
|
||||
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'root-token')
|
||||
->set('expiresInDays', 30)
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $owner->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->abilities)->toBe(['root']);
|
||||
});
|
||||
@@ -78,26 +78,29 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
|
||||
// Add environment variables that should be deleted
|
||||
EnvironmentVariable::create([
|
||||
'application_id' => $application->id,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'SERVICE_FQDN_APP',
|
||||
'value' => 'app.example.com',
|
||||
'is_build_time' => false,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'application_id' => $application->id,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => 'http://app.example.com',
|
||||
'is_build_time' => false,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'application_id' => $application->id,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'REGULAR_VAR',
|
||||
'value' => 'should_remain',
|
||||
'is_build_time' => false,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
@@ -117,6 +120,87 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('model clears dockerfile fields when build_pack changes from dockerfile to railpack', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM node:18',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
'dockerfile_target_build' => 'production',
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
$application->build_pack = 'railpack';
|
||||
$application->save();
|
||||
$application->refresh();
|
||||
|
||||
expect($application->build_pack)->toBe('railpack');
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => '{"app": "example.com"}',
|
||||
'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx',
|
||||
]);
|
||||
|
||||
// Add environment variables that should be deleted
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'SERVICE_FQDN_APP',
|
||||
'value' => 'app.example.com',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => 'http://app.example.com',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'REGULAR_VAR',
|
||||
'value' => 'should_remain',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$application->build_pack = 'railpack';
|
||||
$application->save();
|
||||
$application->refresh();
|
||||
|
||||
expect($application->build_pack)->toBe('railpack');
|
||||
expect($application->docker_compose_domains)->toBeNull();
|
||||
expect($application->docker_compose_raw)->toBeNull();
|
||||
|
||||
// Verify SERVICE_FQDN_* and SERVICE_URL_* were deleted
|
||||
expect($application->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
|
||||
expect($application->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
|
||||
|
||||
// Verify regular variables remain
|
||||
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('model does not clear dockerfile fields when switching to dockerfile', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
@@ -156,6 +240,27 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
});
|
||||
|
||||
test('dockerfile location defaults only for dockerfile buildpack', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$nixpacksApplication = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'nixpacks',
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
$dockerfileApplication = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
expect($nixpacksApplication->refresh()->dockerfile_location)->toBeNull();
|
||||
expect($dockerfileApplication->refresh()->dockerfile_location)->toBe('/Dockerfile');
|
||||
});
|
||||
|
||||
test('model does not trigger cleanup when build_pack is not changed', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function configurationChangedTestApplication(array $attributes = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'build_command' => 'npm run build',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function configurationChangedDeployment(Application $application): ApplicationDeploymentQueue
|
||||
{
|
||||
return ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
}
|
||||
|
||||
it('stores deployment configuration snapshot and clears pending changes', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$deployment = configurationChangedDeployment($application);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
expect($deployment->refresh()->configuration_hash)->not->toBeNull()
|
||||
->and($deployment->configuration_snapshot)->toBeArray()
|
||||
->and($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('stores a diff between successful deployments', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$firstDeployment = configurationChangedDeployment($application);
|
||||
$application->markDeploymentConfigurationApplied($firstDeployment);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
$secondDeployment = configurationChangedDeployment($application->refresh());
|
||||
$application->markDeploymentConfigurationApplied($secondDeployment);
|
||||
|
||||
expect($secondDeployment->refresh()->configuration_diff['count'])->toBe(1)
|
||||
->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command');
|
||||
});
|
||||
|
||||
it('checks legacy preview deployment configuration hash using preview environment variable query', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'preview',
|
||||
'is_preview' => true,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$application->forceFill([
|
||||
'config_hash' => 'legacy-hash',
|
||||
'pull_request_id' => 123,
|
||||
]);
|
||||
|
||||
$diff = $application->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->count())->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$application->isConfigurationChanged(save: true);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->isLegacyFallback())->toBeFalse()
|
||||
->and($diff->count())->toBeGreaterThan(0)
|
||||
->and(collect($diff->changes())->pluck('label')->toArray())->toContain('Build command');
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
<?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\Facades\DB;
|
||||
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' => 'custom-nginx-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 customNginxApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function customNginxConfig(): string
|
||||
{
|
||||
return <<<'NGINX'
|
||||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGINX;
|
||||
}
|
||||
|
||||
function makeCustomNginxApplication(array $overrides = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'build_pack' => 'static',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} custom_nginx_configuration', function () {
|
||||
test('decodes base64 custom nginx configuration before storing it', function () {
|
||||
$application = makeCustomNginxApplication();
|
||||
$configuration = customNginxConfig();
|
||||
$encodedConfiguration = base64_encode($configuration);
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => $encodedConfiguration,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$application->refresh();
|
||||
expect($application->custom_nginx_configuration)->toBe($configuration);
|
||||
|
||||
$storedConfiguration = DB::table('applications')
|
||||
->where('id', $application->id)
|
||||
->value('custom_nginx_configuration');
|
||||
|
||||
expect($storedConfiguration)->toBe(base64_encode($configuration));
|
||||
|
||||
$this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}")
|
||||
->assertOk()
|
||||
->assertJsonPath('custom_nginx_configuration', $configuration);
|
||||
});
|
||||
|
||||
test('rejects custom nginx configuration that is not base64 encoded', function () {
|
||||
$application = makeCustomNginxApplication();
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => customNginxConfig(),
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonPath('errors.custom_nginx_configuration', 'The custom_nginx_configuration should be base64 encoded.');
|
||||
});
|
||||
|
||||
test('can clear custom nginx configuration with null', function () {
|
||||
$application = makeCustomNginxApplication([
|
||||
'custom_nginx_configuration' => customNginxConfig(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => null,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$application->refresh();
|
||||
expect($application->custom_nginx_configuration)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/public custom_nginx_configuration', function () {
|
||||
test('decodes base64 custom nginx configuration before storing it on create', function () {
|
||||
$configuration = customNginxConfig();
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($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-static-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'static',
|
||||
'ports_exposes' => '80',
|
||||
'custom_nginx_configuration' => base64_encode($configuration),
|
||||
'autogenerate_domain' => false,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($application->custom_nginx_configuration)->toBe($configuration);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
public array $recordedCommands = [];
|
||||
|
||||
public ?string $writtenDockerfile = null;
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function execute_remote_command(...$commands)
|
||||
{
|
||||
$this->recordedCommands[] = $commands;
|
||||
|
||||
foreach ($commands as $command) {
|
||||
$commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command;
|
||||
|
||||
if (! is_string($commandString)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) {
|
||||
$this->writtenDockerfile = base64_decode($matches[1]) ?: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeploymentControlVarFixture(array $applicationAttributes = []): array
|
||||
{
|
||||
$team = Team::create([
|
||||
'name' => 'Control Var Team',
|
||||
'description' => 'Team for deployment control var tests.',
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]);
|
||||
$project = Project::create([
|
||||
'name' => 'Control Var Project',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$environment = Environment::where('project_id', $project->id)->firstOrFail();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
...$applicationAttributes,
|
||||
]);
|
||||
|
||||
$application->settings()->update([
|
||||
'inject_build_args_to_dockerfile' => true,
|
||||
'include_source_commit_in_build' => false,
|
||||
'is_env_sorting_enabled' => false,
|
||||
]);
|
||||
|
||||
return [$application->fresh(), $server];
|
||||
}
|
||||
|
||||
function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable
|
||||
{
|
||||
return EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array
|
||||
{
|
||||
$job = new TestableControlVarFilteringDeploymentJob;
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
|
||||
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
|
||||
$queue->shouldReceive('addLogEntry')->andReturnNull();
|
||||
|
||||
$properties = [
|
||||
'application' => $application->fresh(),
|
||||
'application_deployment_queue' => $queue,
|
||||
'build_pack' => $application->build_pack,
|
||||
'mainServer' => $server,
|
||||
'pull_request_id' => 0,
|
||||
'commit' => 'HEAD',
|
||||
'workdir' => '/artifacts/test-app',
|
||||
'deployment_uuid' => 'deployment-uuid',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
'container_name' => 'control-var-app',
|
||||
'coolify_variables' => null,
|
||||
'dockerSecretsSupported' => false,
|
||||
];
|
||||
|
||||
$mergedProperties = array_merge($properties, $overrides);
|
||||
$mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []);
|
||||
|
||||
if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) {
|
||||
$mergedProperties['preview'] = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $mergedProperties['pull_request_id'],
|
||||
'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'],
|
||||
'fqdn' => 'https://preview.example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($mergedProperties as $property => $value) {
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
return [$job, $reflection];
|
||||
}
|
||||
|
||||
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed
|
||||
{
|
||||
$reflectionMethod = $reflection->getMethod($method);
|
||||
$reflectionMethod->setAccessible(true);
|
||||
|
||||
return $reflectionMethod->invoke($job);
|
||||
}
|
||||
|
||||
function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
|
||||
{
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
|
||||
return $reflectionProperty->getValue($job);
|
||||
}
|
||||
|
||||
it('filters buildpack control vars from generic build args', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
|
||||
|
||||
/** @var Collection $envArgs */
|
||||
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
|
||||
|
||||
expect($envArgs->get('APP_ENV'))->toBe('production');
|
||||
expect($envArgs->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
|
||||
expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('filters buildpack control vars from preview build-time env files', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_preview' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_preview' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_preview' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 42,
|
||||
]);
|
||||
|
||||
/** @var Collection $buildtimeEnvs */
|
||||
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
|
||||
|
||||
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue();
|
||||
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
|
||||
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not let preview docker compose service names override generated build-time service names', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
postgresapp:
|
||||
image: postgres:16-alpine
|
||||
YAML;
|
||||
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $compose,
|
||||
'docker_compose' => $compose,
|
||||
'docker_compose_domains' => '[]',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 241,
|
||||
]);
|
||||
|
||||
/** @var Collection $buildtimeEnvs */
|
||||
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
|
||||
$envString = $buildtimeEnvs->implode("\n");
|
||||
|
||||
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp-pr-241'");
|
||||
expect($envString)->not->toContain('SERVICE_NAME_POSTGRESAPP=""');
|
||||
expect($envString)->not->toContain('SERVICE_URL_APP=');
|
||||
});
|
||||
|
||||
it('does not let production docker compose service names override generated build-time service names', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
postgresapp:
|
||||
image: postgres:16-alpine
|
||||
YAML;
|
||||
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $compose,
|
||||
'docker_compose' => $compose,
|
||||
'docker_compose_domains' => '[]',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => 'stale-postgresapp',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
|
||||
|
||||
/** @var Collection $buildtimeEnvs */
|
||||
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
|
||||
$envString = $buildtimeEnvs->implode("\n");
|
||||
|
||||
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp'");
|
||||
expect($envString)->not->toContain('stale-postgresapp');
|
||||
});
|
||||
|
||||
it('filters docker compose generated service variables from build args', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => 'https://preview.example.com',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 241,
|
||||
]);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
|
||||
|
||||
/** @var Collection $envArgs */
|
||||
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
|
||||
|
||||
expect($envArgs->get('APP_ENV'))->toBe('production');
|
||||
expect($envArgs->has('SERVICE_NAME_POSTGRESAPP'))->toBeFalse();
|
||||
expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('filters buildpack control vars from preview runtime env fallback', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_NAME',
|
||||
'value' => 'coolify',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'PREVIEW_FLAG',
|
||||
'value' => 'enabled',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
|
||||
$application->environment_variables_preview()
|
||||
->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION'])
|
||||
->delete();
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 99,
|
||||
]);
|
||||
|
||||
/** @var Collection $runtimeEnvs */
|
||||
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
|
||||
|
||||
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeTrue();
|
||||
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue();
|
||||
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
|
||||
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('filters buildpack control vars from dockerfile arg injection', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'saved_outputs' => [
|
||||
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
|
||||
],
|
||||
]);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
|
||||
|
||||
expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production');
|
||||
expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
|
||||
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
|
||||
});
|
||||
|
||||
it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'railpack',
|
||||
'fqdn' => 'https://railpack.example.com',
|
||||
'install_command' => 'pnpm install --frozen-lockfile',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RUNTIME_ONLY',
|
||||
'value' => 'runtime',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
|
||||
'build_pack' => 'railpack',
|
||||
'branch' => 'main',
|
||||
]);
|
||||
|
||||
/** @var Collection $variables */
|
||||
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
|
||||
|
||||
expect($variables->get('APP_ENV'))->toBe('production');
|
||||
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
|
||||
expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile');
|
||||
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
|
||||
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
|
||||
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
|
||||
expect($variables->has('RUNTIME_ONLY'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('builds preview railpack variables without leaking stale nixpacks vars', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'railpack',
|
||||
'fqdn' => 'https://railpack.example.com',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'PREVIEW_BUILD_FLAG',
|
||||
'value' => 'enabled',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'PREVIEW_RUNTIME_ONLY',
|
||||
'value' => 'runtime',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
|
||||
'build_pack' => 'railpack',
|
||||
'branch' => 'feature/railpack',
|
||||
'pull_request_id' => 123,
|
||||
]);
|
||||
|
||||
/** @var Collection $variables */
|
||||
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
|
||||
|
||||
expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled');
|
||||
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
|
||||
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
|
||||
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
|
||||
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
|
||||
expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
|
||||
});
|
||||
|
||||
test('existing application buildpack selector lists nixpacks before railpack', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSeeInOrder([
|
||||
'<option value="nixpacks">Nixpacks</option>',
|
||||
'<option value="railpack">Railpack (Beta)</option>',
|
||||
], false);
|
||||
});
|
||||
|
||||
test('existing application shows railpack beta label in build pack selector', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'railpack',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSee('Railpack (Beta)');
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
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\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
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']);
|
||||
|
||||
$this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
CleanupPreviewDeployment::shouldRun()->andReturn([
|
||||
'cancelled_deployments' => 0,
|
||||
'killed_containers' => 0,
|
||||
'status' => 'success',
|
||||
]);
|
||||
});
|
||||
|
||||
function previewAuthHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function createTeamApiToken(User $user, Team $team, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createPreview(Application $application, int $pullRequestId): ApplicationPreview
|
||||
{
|
||||
return ApplicationPreview::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}",
|
||||
'fqdn' => "pr-{$pullRequestId}.example.com",
|
||||
]);
|
||||
}
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
|
||||
test('returns 401 when no bearer token provided', function () {
|
||||
$response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid does not exist', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42');
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Application not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when preview does not exist for the application', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999");
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Preview not found.']);
|
||||
});
|
||||
|
||||
test('returns 422 when pull_request_id is not a positive integer', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0");
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJson(['message' => 'Invalid pull_request_id.']);
|
||||
});
|
||||
|
||||
test('soft-deletes the preview and returns 200 on success', function () {
|
||||
$preview = createPreview($this->application, 42);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Preview deletion request queued.']);
|
||||
|
||||
expect($preview->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns 403 when token lacks write ability', function () {
|
||||
$readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
|
||||
createPreview($this->application, 7);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($readOnlyToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7");
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
|
||||
function makePreviewImageNameJob(string $commit, int $pullRequestId = 42, ?string $registryImageName = null, string $deploymentUuid = 'deployment-uuid'): object
|
||||
{
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$application = new Application;
|
||||
$application->uuid = 'preview-app';
|
||||
$application->build_pack = 'dockerfile';
|
||||
$application->dockerfile = null;
|
||||
$application->docker_registry_image_name = $registryImageName;
|
||||
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'commit' => $commit,
|
||||
'deployment_uuid' => $deploymentUuid,
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
function generatePreviewImageNames(object $job): array
|
||||
{
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $reflection->getMethod('generate_image_names');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
$buildImageName = $reflection->getProperty('build_image_name');
|
||||
$buildImageName->setAccessible(true);
|
||||
|
||||
$productionImageName = $reflection->getProperty('production_image_name');
|
||||
$productionImageName->setAccessible(true);
|
||||
|
||||
return [
|
||||
'build' => $buildImageName->getValue($job),
|
||||
'production' => $productionImageName->getValue($job),
|
||||
];
|
||||
}
|
||||
|
||||
it('includes the pull request id and commit in preview image names', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
|
||||
it('generates different preview image names for different commits on the same pull request', function () {
|
||||
$firstCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
$secondCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
expect($firstCommitNames['production'])->not->toBe($secondCommitNames['production'])
|
||||
->and($firstCommitNames['build'])->not->toBe($secondCommitNames['build']);
|
||||
});
|
||||
|
||||
it('uses the deployment uuid for preview image names when commit is HEAD', function () {
|
||||
$firstDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'HEAD',
|
||||
pullRequestId: 123,
|
||||
deploymentUuid: 'deployment-one',
|
||||
));
|
||||
$secondDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'HEAD',
|
||||
pullRequestId: 123,
|
||||
deploymentUuid: 'deployment-two',
|
||||
));
|
||||
|
||||
expect($firstDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-one')
|
||||
->and($firstDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-one-build')
|
||||
->and($secondDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-two')
|
||||
->and($secondDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-two-build');
|
||||
});
|
||||
|
||||
it('uses the configured registry image name for commit-specific preview tags', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 123,
|
||||
registryImageName: 'registry.example.com/team/app',
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
|
||||
it('sanitizes and truncates preview image tags to docker tag limits', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: str_repeat('feature/add dockerfile changes/', 10),
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
$productionTag = str($names['production'])->after(':')->toString();
|
||||
$buildTag = str($names['build'])->after(':')->toString();
|
||||
|
||||
expect(strlen($productionTag))->toBeLessThanOrEqual(128)
|
||||
->and(strlen($buildTag))->toBeLessThanOrEqual(128)
|
||||
->and($productionTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+$/')
|
||||
->and($buildTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+-build$/');
|
||||
});
|
||||
|
||||
it('keeps non-preview dockerfile image names commit based', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 0,
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Application Railpack Support', function () {
|
||||
beforeEach(function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
});
|
||||
|
||||
test('could_set_build_commands returns true for railpack', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
expect($application->could_set_build_commands())->toBeTrue();
|
||||
});
|
||||
|
||||
test('could_set_build_commands returns true for nixpacks', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'nixpacks',
|
||||
]);
|
||||
|
||||
expect($application->could_set_build_commands())->toBeTrue();
|
||||
});
|
||||
|
||||
test('could_set_build_commands returns false for dockerfile', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
]);
|
||||
|
||||
expect($application->could_set_build_commands())->toBeFalse();
|
||||
});
|
||||
|
||||
test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'REGULAR_VAR',
|
||||
'value' => 'value',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '18',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$railpackVars = $application->railpack_environment_variables;
|
||||
expect($railpackVars)->toHaveCount(1);
|
||||
expect($railpackVars->first()->key)->toBe('RAILPACK_NODE_VERSION');
|
||||
});
|
||||
|
||||
test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '18',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$runtimeVars = $application->runtime_environment_variables;
|
||||
expect($runtimeVars)->toHaveCount(1);
|
||||
expect($runtimeVars->first()->key)->toBe('APP_ENV');
|
||||
});
|
||||
|
||||
test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'RAILPACK_BUILD_CMD',
|
||||
'value' => 'npm run build',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => true,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'REGULAR_VAR',
|
||||
'value' => 'value',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => true,
|
||||
]);
|
||||
|
||||
$previewVars = $application->railpack_environment_variables_preview;
|
||||
expect($previewVars)->toHaveCount(1);
|
||||
expect($previewVars->first()->key)->toBe('RAILPACK_BUILD_CMD');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Database\Seeders\ApplicationSeeder;
|
||||
use Database\Seeders\GithubAppSeeder;
|
||||
use Database\Seeders\PrivateKeySeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Database\Seeders\ServerSeeder;
|
||||
use Database\Seeders\StandaloneDockerSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('seeds a railpack nodejs fastify example alongside the existing nixpacks example', function () {
|
||||
$this->seed([
|
||||
UserSeeder::class,
|
||||
TeamSeeder::class,
|
||||
PrivateKeySeeder::class,
|
||||
ServerSeeder::class,
|
||||
ProjectSeeder::class,
|
||||
StandaloneDockerSeeder::class,
|
||||
GithubAppSeeder::class,
|
||||
ApplicationSeeder::class,
|
||||
]);
|
||||
|
||||
$nixpacksExample = Application::where('uuid', 'nodejs')->first();
|
||||
$railpackExample = Application::where('uuid', 'railpack-nodejs')->first();
|
||||
|
||||
expect($nixpacksExample)
|
||||
->not->toBeNull()
|
||||
->and($nixpacksExample->name)->toBe('NodeJS Fastify Example')
|
||||
->and($nixpacksExample->build_pack)->toBe('nixpacks')
|
||||
->and($nixpacksExample->base_directory)->toBe('/nodejs')
|
||||
->and($nixpacksExample->ports_exposes)->toBe('3000');
|
||||
|
||||
expect($railpackExample)
|
||||
->not->toBeNull()
|
||||
->and($railpackExample->name)->toBe('Railpack NodeJS Fastify Example')
|
||||
->and($railpackExample->fqdn)->toBe('http://railpack-nodejs.127.0.0.1.sslip.io')
|
||||
->and($railpackExample->repository_project_id)->toBe(603035348)
|
||||
->and($railpackExample->git_repository)->toBe('coollabsio/coolify-examples')
|
||||
->and($railpackExample->git_branch)->toBe('v4.x')
|
||||
->and($railpackExample->base_directory)->toBe('/nodejs')
|
||||
->and($railpackExample->build_pack)->toBe('railpack')
|
||||
->and($railpackExample->ports_exposes)->toBe('3000')
|
||||
->and($railpackExample->environment_id)->toBe(1)
|
||||
->and($railpackExample->destination_id)->toBe(0)
|
||||
->and($railpackExample->source_id)->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
function renderApplicationServerStatusBadge(?bool $serverStatus, string $status = 'running', bool $hasAdditionalServers = false): string
|
||||
{
|
||||
$application = new class($serverStatus, $status, $hasAdditionalServers)
|
||||
{
|
||||
public function __construct(
|
||||
public ?bool $server_status,
|
||||
public string $status,
|
||||
private bool $hasAdditionalServers,
|
||||
) {}
|
||||
|
||||
public function additional_servers(): object
|
||||
{
|
||||
return new class($this->hasAdditionalServers)
|
||||
{
|
||||
public function __construct(private bool $exists) {}
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->exists;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return view('livewire.project.application.server-status-badge', [
|
||||
'application' => $application,
|
||||
])->render();
|
||||
}
|
||||
|
||||
it('does not show the unreachable server badge when server status is unknown', function () {
|
||||
$html = renderApplicationServerStatusBadge(null);
|
||||
|
||||
expect($html)->not->toContain('One or more servers are unreachable or misconfigured.');
|
||||
});
|
||||
|
||||
it('shows the unreachable server badge only when server status is false', function () {
|
||||
$html = renderApplicationServerStatusBadge(false);
|
||||
|
||||
expect($html)->toContain('One or more servers are unreachable or misconfigured.');
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Source;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Create a PrivateKey without firing model events. The PrivateKey `saving`
|
||||
* hook validates/fingerprints real key material and the `saved` hook writes
|
||||
* to the filesystem — neither is wanted in a unit test. Skipping events also
|
||||
* skips BaseModel's uuid generation, so the uuid is set explicitly here (it
|
||||
* is not in $fillable, so it cannot go through mass assignment).
|
||||
*/
|
||||
function makePrivateKey(string $name, string $material, string $fingerprint, int $teamId): PrivateKey
|
||||
{
|
||||
return PrivateKey::withoutEvents(function () use ($name, $material, $fingerprint, $teamId) {
|
||||
$key = new PrivateKey([
|
||||
'name' => $name,
|
||||
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\n{$material}\n-----END OPENSSH PRIVATE KEY-----",
|
||||
'fingerprint' => $fingerprint,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$key->uuid = (string) new Cuid2;
|
||||
$key->save();
|
||||
|
||||
return $key;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// handleError() turns a ModelNotFoundException into abort(404); rendering the 404
|
||||
// page reads InstanceSettings::get(), which findOrFail(0)s. Seed the singleton row.
|
||||
// `id` is not in $fillable, so it must be set outside of mass assignment.
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
// Team A — the attacker
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->teamA->members()->attach($this->userA->id, ['role' => 'owner']);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
$this->applicationA = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'private_key_id' => null,
|
||||
'source_id' => null,
|
||||
'source_type' => null,
|
||||
]);
|
||||
|
||||
// Team B — the victim (holds the secrets we are trying to steal)
|
||||
$this->teamB = Team::factory()->create();
|
||||
|
||||
$this->victimPrivateKey = makePrivateKey('victim-ssh-key', 'VICTIM_KEY_MATERIAL', 'victim-fingerprint', $this->teamB->id);
|
||||
|
||||
$this->victimGithubApp = GithubApp::create([
|
||||
'name' => 'victim-github-app',
|
||||
'team_id' => $this->teamB->id,
|
||||
'private_key_id' => $this->victimPrivateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('setPrivateKey rejects a PrivateKey owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->call('setPrivateKey', $this->victimPrivateKey->id);
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->private_key_id)->not->toBe($this->victimPrivateKey->id);
|
||||
expect($this->applicationA->private_key_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('setPrivateKey accepts a PrivateKey owned by the current team', function () {
|
||||
$ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id);
|
||||
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->call('setPrivateKey', $ownKey->id);
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->private_key_id)->toBe($ownKey->id);
|
||||
});
|
||||
|
||||
test('changeSource rejects a GithubApp owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->call('changeSource', $this->victimGithubApp->id, GithubApp::class);
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->source_id)->not->toBe($this->victimGithubApp->id);
|
||||
expect($this->applicationA->source_type)->not->toBe(GithubApp::class);
|
||||
});
|
||||
|
||||
test('changeSource rejects an arbitrary class as source_type', function () {
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->call('changeSource', $this->victimGithubApp->id, Server::class);
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->source_type)->not->toBe(Server::class);
|
||||
});
|
||||
|
||||
test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () {
|
||||
// Without #[Locked], an attacker could POST {"updates": {"privateKeyId": <foreign_id>},
|
||||
// "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through
|
||||
// Application::update(['private_key_id' => $this->privateKeyId]) — bypassing setPrivateKey()
|
||||
// and its team-scoped lookup entirely. Locking the property closes that path at the wire layer.
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->set('privateKeyId', $this->victimPrivateKey->id);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
@@ -24,12 +24,23 @@ beforeEach(function () {
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function applicationSourceValidPrivateKey(): string
|
||||
{
|
||||
return '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
describe('Application Source with localhost key (id=0)', function () {
|
||||
test('renders deploy key section when private_key_id is 0', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'id' => 0,
|
||||
'name' => 'localhost',
|
||||
'private_key' => 'test-key-content',
|
||||
'private_key' => applicationSourceValidPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
@@ -56,4 +67,19 @@ describe('Application Source with localhost key (id=0)', function () {
|
||||
->assertDontSee('Deploy Key')
|
||||
->assertSee('No source connected');
|
||||
});
|
||||
|
||||
test('dispatches configuration changed when source settings are saved', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'git_repository' => 'coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
Livewire::test(Source::class, ['application' => $application])
|
||||
->set('gitBranch', 'next')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createBackupForEditValidationTest(Team $team, array $overrides = []): ScheduledDatabaseBackup
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-backup-edit-validation',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
return ScheduledDatabaseBackup::create(array_merge([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'database_id' => $database->id,
|
||||
'team_id' => $team->id,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
if (InstanceSettings::find(0) === null) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('disables S3 backup when saved without a selected S3 storage', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('cascades to disabling local backup deletion when S3 is force-disabled', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'disable_local_backup' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
expect($backup->disable_local_backup)->toBeFalsy();
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// --- WithoutOverlapping middleware on DatabaseBackupJob ---
|
||||
|
||||
test('database backup job has WithoutOverlapping middleware keyed by backup id', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$middleware = $job->middleware();
|
||||
|
||||
expect($middleware)->toHaveCount(1);
|
||||
expect($middleware[0])->toBeInstanceOf(WithoutOverlapping::class);
|
||||
});
|
||||
|
||||
test('database backup job middleware uses custom timeout plus buffer', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'timeout' => 7200,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$middleware = $job->middleware();
|
||||
|
||||
$reflection = new ReflectionClass($middleware[0]);
|
||||
$expiresAfterProp = $reflection->getProperty('expiresAfter');
|
||||
$releaseAfterProp = $reflection->getProperty('releaseAfter');
|
||||
|
||||
expect($expiresAfterProp->getValue($middleware[0]))->toBe(7500); // 7200 + 300
|
||||
expect($releaseAfterProp->getValue($middleware[0]))->toBeNull(); // dontRelease sets to null
|
||||
});
|
||||
|
||||
// --- Stale backup detection in DatabaseBackupJob ---
|
||||
|
||||
test('markStaleExecutionsAsFailed marks stale running executions as failed', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
|
||||
// Create a stale execution (3 hours old, timeout is 1h, threshold is 2h)
|
||||
$staleExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'stale-exec-uuid',
|
||||
'database_name' => 'test_db',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'running',
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $staleExecution->id)
|
||||
->update(['created_at' => now()->subHours(3)]);
|
||||
|
||||
// Call the private method via reflection
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$reflection = new ReflectionClass($job);
|
||||
$method = $reflection->getMethod('markStaleExecutionsAsFailed');
|
||||
$method->invoke($job);
|
||||
|
||||
$staleExecution->refresh();
|
||||
expect($staleExecution->status)->toBe('failed');
|
||||
expect($staleExecution->message)->toContain('exceeded maximum allowed time');
|
||||
expect($staleExecution->finished_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('markStaleExecutionsAsFailed does not mark recent running executions as failed', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
|
||||
// Create a recent execution (30 minutes old, threshold is 2 hours)
|
||||
$recentExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'recent-exec-uuid',
|
||||
'database_name' => 'test_db',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'running',
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $recentExecution->id)
|
||||
->update(['created_at' => now()->subMinutes(30)]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$reflection = new ReflectionClass($job);
|
||||
$method = $reflection->getMethod('markStaleExecutionsAsFailed');
|
||||
$method->invoke($job);
|
||||
|
||||
$recentExecution->refresh();
|
||||
expect($recentExecution->status)->toBe('running');
|
||||
expect($recentExecution->finished_at)->toBeNull();
|
||||
});
|
||||
|
||||
// --- Decimal cast on ScheduledDatabaseBackup model ---
|
||||
|
||||
test('scheduled database backup model casts max storage fields to float', function () {
|
||||
$model = new ScheduledDatabaseBackup;
|
||||
$casts = $model->getCasts();
|
||||
|
||||
expect($casts)->toHaveKey('database_backup_retention_max_storage_locally');
|
||||
expect($casts['database_backup_retention_max_storage_locally'])->toBe('float');
|
||||
expect($casts)->toHaveKey('database_backup_retention_max_storage_s3');
|
||||
expect($casts['database_backup_retention_max_storage_s3'])->toBe('float');
|
||||
});
|
||||
|
||||
test('max storage retention field returns float zero that passes strict comparison', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_max_storage_locally' => 0,
|
||||
]);
|
||||
|
||||
$backup->refresh();
|
||||
|
||||
// With the float cast, strict comparison to 0.0 works
|
||||
expect($backup->database_backup_retention_max_storage_locally)->toBe(0.0);
|
||||
// And loose comparison to int 0 also works
|
||||
expect($backup->database_backup_retention_max_storage_locally == 0)->toBeTrue();
|
||||
});
|
||||
|
||||
// --- Periodic retention enforcement in CleanupInstanceStuffsJob ---
|
||||
|
||||
test('cleanup instance stuffs job calls retention enforcement without errors', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'enabled' => true,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_locally' => 1,
|
||||
]);
|
||||
|
||||
// Create 3 successful executions
|
||||
foreach (range(1, 3) as $i) {
|
||||
ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => "exec-uuid-{$i}",
|
||||
'database_name' => 'test_db',
|
||||
'filename' => "/backup/test_{$i}.dmp",
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'size' => 1024,
|
||||
'local_storage_deleted' => false,
|
||||
'created_at' => now()->subDays($i),
|
||||
]);
|
||||
}
|
||||
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
// Should not throw — the job gracefully handles missing servers
|
||||
$job = new CleanupInstanceStuffsJob;
|
||||
$job->handle();
|
||||
|
||||
// All 3 executions should still exist (no server to delete files from)
|
||||
expect($backup->executions()->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('deleteOldBackupsLocally identifies correct backups for deletion by retention count', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'enabled' => true,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_locally' => 1,
|
||||
]);
|
||||
|
||||
foreach (range(1, 3) as $i) {
|
||||
$exec = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => "exec-uuid-{$i}",
|
||||
'database_name' => 'test_db',
|
||||
'filename' => "/backup/test_{$i}.dmp",
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'size' => 1024,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $exec->id)
|
||||
->update(['created_at' => now()->subDays($i)]);
|
||||
}
|
||||
|
||||
// Test the selection logic directly
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->where('local_storage_deleted', false)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_locally;
|
||||
$backupsToDelete = $successfulBackups->skip($retentionAmount);
|
||||
|
||||
// exec-uuid-1 is newest (1 day ago), exec-uuid-2 and exec-uuid-3 should be deleted
|
||||
expect($backupsToDelete)->toHaveCount(2);
|
||||
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
|
||||
});
|
||||
|
||||
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
$job = new CleanupInstanceStuffsJob;
|
||||
$job->handle();
|
||||
|
||||
// The cache key should now exist
|
||||
expect(Cache::has('backup-retention-enforcement'))->toBeTrue();
|
||||
|
||||
// Running again should skip retention enforcement (cache hit)
|
||||
// We verify by checking the lock is still held — no error thrown
|
||||
$job->handle();
|
||||
|
||||
expect(Cache::has('backup-retention-enforcement'))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$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]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'Pure Dockerfile Example',
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides the breadcrumb trail on mobile while keeping the current status visible', function () {
|
||||
$response = $this->get(route('project.application.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('flex min-w-0 flex-col gap-1 md:hidden', false);
|
||||
$response->assertSee('flex min-w-0 items-center text-xs text-neutral-400', false);
|
||||
$response->assertSee('hidden flex-wrap items-center gap-y-1 md:flex', false);
|
||||
$response->assertSee('flex flex-wrap items-center gap-1', false);
|
||||
$response->assertSee(
|
||||
'scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:w-auto md:overflow-visible',
|
||||
false,
|
||||
);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('Actions');
|
||||
$response->assertSee('dropdown-item-touch', false);
|
||||
$response->assertSee('hidden flex-wrap items-center gap-2 md:flex', false);
|
||||
$response->assertSee('window.innerWidth >= 768', false);
|
||||
$response->assertSee(':style="panelStyles"', false);
|
||||
$response->assertSee('absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6', false);
|
||||
$response->assertSee('Pure Dockerfile Example');
|
||||
$response->assertSee('Running');
|
||||
$response->assertSee('pt-2 pb-4 md:pb-10', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('hidden pt-2 pb-10 md:flex');
|
||||
});
|
||||
@@ -111,6 +111,29 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
});
|
||||
|
||||
test('clears dockerfile fields when switching from dockerfile to railpack', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM node:18',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
'dockerfile_target_build' => 'production',
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->set('buildPack', 'railpack')
|
||||
->call('updatedBuildPack');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->build_pack)->toBe('railpack');
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('clears dockerfile fields when switching from dockerfile to dockercompose', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
|
||||
@@ -6,7 +6,30 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('cleans up servers with unreachable_count >= 3 after 7 days', function () {
|
||||
it('disables (non-destructively) self-hosted servers with unreachable_count >= 3 after 7 days', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 50,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
// IP must be preserved — never overwritten on self-hosted.
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
expect($server->settings->force_disabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('overwrites the IP with 1.2.3.4 on cloud for servers with unreachable_count >= 3 after 7 days', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
@@ -30,12 +53,13 @@ it('does not clean up servers with unreachable_count less than 3', function () {
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
expect($server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not clean up servers updated within 7 days', function () {
|
||||
@@ -47,12 +71,13 @@ it('does not clean up servers updated within 7 days', function () {
|
||||
'updated_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
expect($server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not clean up servers without notification sent', function () {
|
||||
@@ -64,10 +89,11 @@ it('does not clean up servers without notification sent', function () {
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
expect($server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('sets unreachable fields on servers when subscription ends', function () {
|
||||
$team = Team::factory()->create();
|
||||
Subscription::create([
|
||||
'team_id' => $team->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->unreachable_count)->toBe(3);
|
||||
expect($server->unreachable_notification_sent)->toBeTrue();
|
||||
});
|
||||
|
||||
it('cleans up unsubscribed server IP after 7 days via cleanup command', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 3,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe('1.2.3.4');
|
||||
});
|
||||
|
||||
it('does not clean up unsubscribed server IP within 7 day grace period', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 3,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect((string) $server->ip)->toBe($originalIp);
|
||||
});
|
||||
|
||||
it('does not affect servers with active subscriptions', function () {
|
||||
$team = Team::factory()->create();
|
||||
Subscription::create([
|
||||
'team_id' => $team->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
]);
|
||||
|
||||
$originalCount = $server->unreachable_count;
|
||||
$originalNotification = $server->unreachable_notification_sent;
|
||||
|
||||
expect($originalCount)->toBe(0);
|
||||
expect($originalNotification)->toBeFalse();
|
||||
});
|
||||
@@ -414,7 +414,7 @@ describe('docker_compose_custom_command validation', function () {
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects single quotes in docker_compose_custom_start_command', function () {
|
||||
test('allows single-quoted arguments in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
@@ -422,7 +422,7 @@ describe('docker_compose_custom_command validation', function () {
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('allows double quotes in docker_compose_custom_start_command', function () {
|
||||
@@ -474,6 +474,127 @@ describe('docker_compose_custom_command validation', function () {
|
||||
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
|
||||
->toBe('docker compose up -d --build');
|
||||
});
|
||||
|
||||
test('rejects bare ampersand PoC payload (GHSA-chg4-63hm-xv9x)', function () {
|
||||
$rules = sharedDataApplications();
|
||||
$payload = 'true & docker run --rm -v /:/h alpine sh -c "cp /h/etc/shadow /h/tmp/leak"';
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => $payload],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects bare ampersand across every shell-safe field', function ($field) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
[$field => 'cmd1 & cmd2'],
|
||||
[$field => $rules[$field]]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'install_command',
|
||||
'build_command',
|
||||
'start_command',
|
||||
'docker_compose_custom_build_command',
|
||||
'docker_compose_custom_start_command',
|
||||
'custom_docker_run_options',
|
||||
]);
|
||||
|
||||
test('rejects command substitution inside double quotes', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => "echo $payload"],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['"$(whoami)"', '"`whoami`"']);
|
||||
|
||||
test('rejects unbalanced quotes', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $payload],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['echo "unterminated', "echo 'unterminated"]);
|
||||
|
||||
test('rejects backslash anywhere', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $payload],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['echo \\;', 'echo \\$HOME']);
|
||||
|
||||
test('runtime validateShellSafeCommand rejects bare ampersand payload', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validateShellSafeCommand');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, 'true & whoami', 'docker_compose_custom_start_command'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
|
||||
});
|
||||
|
||||
test('allows logical OR chaining', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'make build || make clean',
|
||||
'npm run build || npm run fallback',
|
||||
'cmd-a || cmd-b && cmd-c',
|
||||
]);
|
||||
|
||||
test('allows glob and bang tokens', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'rm *.tmp',
|
||||
'cp src/?.js dist/',
|
||||
'! grep -q foo && echo missing',
|
||||
'docker build --tag app-v1!',
|
||||
]);
|
||||
|
||||
test('rejects bare pipe even though || is allowed', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'cmd | cat',
|
||||
'cmd|cat',
|
||||
'a |b',
|
||||
'a| b',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('custom_docker_run_options validation', function () {
|
||||
@@ -512,6 +633,9 @@ describe('custom_docker_run_options validation', function () {
|
||||
'--cap-add=NET_ADMIN --cap-add=NET_RAW',
|
||||
'--privileged --init',
|
||||
'--memory=512m --cpus=2',
|
||||
'--entrypoint "sh -c \'npm start\'"',
|
||||
'--entrypoint "sh -c \'php artisan schedule:work\'"',
|
||||
'--hostname "my-host"',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -673,7 +797,7 @@ describe('API route middleware for deploy actions', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () {
|
||||
describe('install/build/start command validation', function () {
|
||||
test('rejects semicolon injection in install_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
|
||||
@@ -60,3 +60,47 @@ test('convertGitUrlsForSourceAndSshUrlWithCustomPort', function () {
|
||||
'port' => '766',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPort', function () {
|
||||
$result = convertGitUrl('ssh://git@192.168.56.11:22222/User/Repo.git', 'source', null);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@192.168.56.11:22222/User/Repo.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPortAndIpv6Host', function () {
|
||||
$result = convertGitUrl('ssh://git@[2001:db8::10]:22222/group/project.git', 'source', null);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@[2001:db8::10]:22222/group/project.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPort', function () {
|
||||
$githubApp = new GithubApp([
|
||||
'html_url' => 'https://github.example.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22222,
|
||||
]);
|
||||
|
||||
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@github.example.com:22222/andrasbacsai/coolify-examples.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPortAndIpv6Host', function () {
|
||||
$githubApp = new GithubApp([
|
||||
'html_url' => 'https://[2001:db8::10]',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22222,
|
||||
]);
|
||||
|
||||
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@[2001:db8::10]:22222/andrasbacsai/coolify-examples.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\CreateScheduledBackup;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createDatabaseForScheduledBackupTest(Team $team): StandalonePostgresql
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return StandalonePostgresql::create([
|
||||
'name' => 'pg-scheduled-backup-validation',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
|
||||
{
|
||||
return S3Storage::create([
|
||||
'name' => $name,
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'is_usable' => true,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('rejects enabling S3 backup without a selected S3 storage', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', null)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage not owned by the current team', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
|
||||
$foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3');
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $foreignS3->id)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage that is reassigned after the component is mounted', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id);
|
||||
|
||||
$s3->update(['team_id' => Team::factory()->create()->id]);
|
||||
|
||||
$component
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage that becomes unusable after the component is mounted', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id);
|
||||
|
||||
$s3->update(['is_usable' => false]);
|
||||
|
||||
$component
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('creates a scheduled backup with a valid team-owned S3 storage', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id)
|
||||
->call('submit')
|
||||
->assertDispatched('refreshScheduledBackups');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->s3_storage_id)->toBe($s3->id);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Livewire\Project\Shared\Destination;
|
||||
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\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
// Attacker: Team A
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverA->id,
|
||||
'name' => 'dest-a-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-a-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
$this->applicationA = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
// A second usable destination on Team A's own server, used for positive-path tests.
|
||||
$this->serverA2 = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA2 = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverA2->id,
|
||||
'name' => 'dest-a2-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-a2-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
// Victim: Team B
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverB->id,
|
||||
'name' => 'dest-b-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-b-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
// Act as attacker (Team A)
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
GetContainersStatus::clearFake();
|
||||
});
|
||||
|
||||
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
|
||||
test('cannot attach another team\'s server + network to own application', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationB->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
// handleError on ModelNotFoundException calls abort(404); pivot assertion is source of truth.
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
expect($this->applicationA->fresh()->additional_servers)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cannot attach own network paired with another team\'s server', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationA2->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cannot attach another team\'s network paired with own server', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationB->id, $this->serverA2->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cannot attach own network paired with wrong own server', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationA2->id, $this->serverA->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('can attach own team\'s server + network to own application', function () {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
$additional = $this->applicationA->fresh()->additional_networks;
|
||||
expect($additional)->toHaveCount(1);
|
||||
expect($additional->first()->id)->toBe($this->destinationA2->id);
|
||||
expect($additional->first()->pivot->server_id)->toBe($this->serverA2->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {
|
||||
test('cannot promote another team\'s network as the application\'s main destination', function () {
|
||||
$originalDestinationId = $this->applicationA->destination_id;
|
||||
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationB->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
|
||||
});
|
||||
|
||||
test('cannot promote own network paired with wrong own server', function () {
|
||||
$originalDestinationId = $this->applicationA->destination_id;
|
||||
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
|
||||
});
|
||||
|
||||
test('can promote own team network and preserve previous main as additional network', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
$application = $this->applicationA->fresh();
|
||||
$additional = $application->additional_networks;
|
||||
|
||||
expect($application->destination_id)->toBe($this->destinationA2->id);
|
||||
expect($additional)->toHaveCount(1);
|
||||
expect($additional->first()->id)->toBe($this->destinationA->id);
|
||||
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
|
||||
});
|
||||
|
||||
test('refresh failures after promote do not roll back promoted destination', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
|
||||
GetContainersStatus::shouldRun()
|
||||
->once()
|
||||
->andThrow(new RuntimeException('refresh failed'));
|
||||
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
} catch (Throwable $e) {
|
||||
// The refresh failure is intentionally outside the transaction; persistence is the assertion.
|
||||
}
|
||||
|
||||
$application = $this->applicationA->fresh();
|
||||
$additional = $application->additional_networks;
|
||||
|
||||
expect($application->destination_id)->toBe($this->destinationA2->id);
|
||||
expect($additional)->toHaveCount(1);
|
||||
expect($additional->first()->id)->toBe($this->destinationA->id);
|
||||
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
|
||||
});
|
||||
|
||||
test('only detaches the promoted network for the selected pivot server', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA->id)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA2->id)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination::removeServer', function () {
|
||||
test('only detaches the removed network for the selected pivot server', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('removeServer', $this->destinationA2->id, $this->serverA2->id, 'password');
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA->id)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA2->id)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Livewire\Boarding\Index as BoardingIndex;
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Livewire\Project\CloneMe;
|
||||
use App\Livewire\Project\DeleteProject;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -39,7 +43,7 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('Boarding Server IDOR', function () {
|
||||
test('boarding mount cannot load server from another team via selectedExistingServer', function () {
|
||||
$component = Livewire::test(BoardingIndex::class, [
|
||||
'selectedServerType' => 'remote',
|
||||
@@ -62,7 +66,7 @@ describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('Boarding Project IDOR', function () {
|
||||
test('boarding mount cannot load project from another team via selectedProject', function () {
|
||||
$component = Livewire::test(BoardingIndex::class, [
|
||||
'selectedProject' => $this->projectB->id,
|
||||
@@ -91,7 +95,7 @@ describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('GlobalSearch Server IDOR', function () {
|
||||
test('loadDestinations cannot access server from another team', function () {
|
||||
$component = Livewire::test(GlobalSearch::class)
|
||||
->set('selectedServerId', $this->serverB->id)
|
||||
@@ -102,7 +106,7 @@ describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('GlobalSearch Project IDOR', function () {
|
||||
test('loadEnvironments cannot access project from another team', function () {
|
||||
$component = Livewire::test(GlobalSearch::class)
|
||||
->set('selectedProjectUuid', $this->projectB->uuid)
|
||||
@@ -113,11 +117,11 @@ describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('DeleteProject IDOR', function () {
|
||||
test('cannot mount DeleteProject with project from another team', function () {
|
||||
// Should throw ModelNotFoundException (404) because team-scoped query won't find it
|
||||
Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]);
|
||||
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('can mount DeleteProject with own team project', function () {
|
||||
$component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]);
|
||||
@@ -126,14 +130,14 @@ describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('CloneMe Project IDOR', function () {
|
||||
test('cannot mount CloneMe with project UUID from another team', function () {
|
||||
// Should throw ModelNotFoundException because team-scoped query won't find it
|
||||
Livewire::test(CloneMe::class, [
|
||||
'project_uuid' => $this->projectB->uuid,
|
||||
'environment_uuid' => $this->environmentB->uuid,
|
||||
]);
|
||||
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('can mount CloneMe with own team project UUID', function () {
|
||||
$component = Livewire::test(CloneMe::class, [
|
||||
@@ -145,21 +149,21 @@ describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('DeployController API Server IDOR', function () {
|
||||
test('deploy cancel API cannot access build server from another team', function () {
|
||||
// Create a deployment queue entry that references Team B's server as build_server
|
||||
$application = \App\Models\Application::factory()->create([
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
$deployment = \App\Models\ApplicationDeploymentQueue::create([
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $application->id,
|
||||
'deployment_uuid' => 'test-deploy-'.fake()->uuid(),
|
||||
'server_id' => $this->serverA->id,
|
||||
'build_server_id' => $this->serverB->id, // Cross-team build server
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
|
||||
$token = $this->userA->createToken('test-token', ['*']);
|
||||
@@ -176,7 +180,7 @@ describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
// Verify the deployment was cancelled
|
||||
$deployment->refresh();
|
||||
expect($deployment->status)->toBe(
|
||||
\App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value
|
||||
ApplicationDeploymentStatus::CANCELLED_BY_USER->value
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -8,50 +15,110 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a team with owner
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
// Create an API token for the user
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Mock a database - we'll use Mockery to avoid needing actual database setup
|
||||
$this->database = \Mockery::mock(StandalonePostgresql::class);
|
||||
$this->database->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid');
|
||||
$this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb');
|
||||
$this->database->shouldReceive('type')->andReturn('standalone-postgresql');
|
||||
$this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
|
||||
});
|
||||
$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]);
|
||||
|
||||
afterEach(function () {
|
||||
\Mockery::close();
|
||||
$this->database = StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'testdb',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->s3Storage = S3Storage::create([
|
||||
'name' => 'test-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->team->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
test('creates backup configuration with minimal required fields', function () {
|
||||
// This is a unit-style test using mocks to avoid database dependency
|
||||
// For full integration testing, this should be run inside Docker
|
||||
test('creates backup with s3 storage via API token', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => '0 2 * * 0',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'message']);
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
expect($backup->team_id)->toBe($this->team->id);
|
||||
});
|
||||
|
||||
test('creates backup without s3 storage', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'message']);
|
||||
});
|
||||
|
||||
test('rejects s3_storage_uuid from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3 = S3Storage::create([
|
||||
'name' => 'other-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'other-key',
|
||||
'secret' => 'other-secret',
|
||||
'bucket' => 'other-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => 'daily',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => '0 2 * * 0',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3->uuid,
|
||||
]);
|
||||
|
||||
// Since we're mocking, this test verifies the endpoint exists and basic validation
|
||||
// Full integration tests should be run in Docker environment
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
|
||||
test('validates frequency is required', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
@@ -63,83 +130,78 @@ describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
]);
|
||||
|
||||
// Should fail validation because s3_storage_uuid is missing
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
});
|
||||
|
||||
test('rejects invalid frequency format', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => 'invalid-frequency',
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
|
||||
test('rejects request without authentication', function () {
|
||||
$response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
$response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
||||
test('validates retention fields are integers with minimum 0', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
|
||||
test('updates backup to use s3 storage via API token', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'database_backup_retention_amount_locally' => -1,
|
||||
'enabled' => true,
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
});
|
||||
|
||||
test('accepts valid cron expressions', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => '0 2 * * *', // Daily at 2 AM
|
||||
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
]);
|
||||
|
||||
// Will fail with 404 because database doesn't exist, but validates the request format
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
$response->assertStatus(200);
|
||||
$backup->refresh();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
});
|
||||
|
||||
test('accepts predefined frequency values', function () {
|
||||
$frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
|
||||
test('rejects s3_storage_uuid from another team on update', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3 = S3Storage::create([
|
||||
'name' => 'other-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'other-key',
|
||||
'secret' => 'other-secret',
|
||||
'bucket' => 'other-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
foreach ($frequencies as $frequency) {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => $frequency,
|
||||
]);
|
||||
|
||||
// Will fail with 404 because database doesn't exist, but validates the request format
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'invalid_field' => 'invalid_value',
|
||||
'enabled' => true,
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,48 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('upload_to_s3 exception message reports the previous s3 storage id', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => 12345,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
$reflection->getProperty('s3')->setValue($job, null);
|
||||
|
||||
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
|
||||
->toThrow(Exception::class, 'S3 storage ID: 12345');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
$reflection->getProperty('s3')->setValue($job, null);
|
||||
|
||||
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
|
||||
->toThrow(Exception::class, 'S3 storage ID: null');
|
||||
});
|
||||
|
||||
test('deleting s3 storage disables s3 on linked backups', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UploadController;
|
||||
|
||||
function invokeHasAllowedExtension(string $name): bool
|
||||
{
|
||||
$method = new ReflectionMethod(UploadController::class, 'hasAllowedExtension');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke(null, $name);
|
||||
}
|
||||
|
||||
test('hasAllowedExtension accepts supported extensions', function (string $name) {
|
||||
expect(invokeHasAllowedExtension($name))->toBeTrue();
|
||||
})->with([
|
||||
'plain sql' => ['backup.sql'],
|
||||
'uppercase sql' => ['BACKUP.SQL'],
|
||||
'compound sql.gz' => ['backup.sql.gz'],
|
||||
'compound tar.gz' => ['backup.tar.gz'],
|
||||
'tgz' => ['archive.tgz'],
|
||||
'zip' => ['dump.zip'],
|
||||
'tar' => ['dump.tar'],
|
||||
'gz' => ['data.gz'],
|
||||
'dump' => ['data.dump'],
|
||||
'bak' => ['data.bak'],
|
||||
'bson' => ['data.bson'],
|
||||
'bson.gz' => ['data.bson.gz'],
|
||||
'archive' => ['data.archive'],
|
||||
'archive.gz' => ['data.archive.gz'],
|
||||
'bz2' => ['data.bz2'],
|
||||
'xz' => ['data.xz'],
|
||||
]);
|
||||
|
||||
test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) {
|
||||
expect(invokeHasAllowedExtension($name))->toBeFalse();
|
||||
})->with([
|
||||
'php' => ['shell.php'],
|
||||
'phtml' => ['shell.phtml'],
|
||||
'sh' => ['run.sh'],
|
||||
'exe' => ['malware.exe'],
|
||||
'elf binary no ext' => ['payload'],
|
||||
'html' => ['index.html'],
|
||||
'bare compound without stem' => ['.sql.gz'],
|
||||
'bare extension' => ['.sql'],
|
||||
'empty string' => [''],
|
||||
'misleading double ext' => ['shell.php.sql-evil'],
|
||||
]);
|
||||
|
||||
test('MAX_BYTES constant is 10 GiB', function () {
|
||||
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
|
||||
expect($constant)->toBe(10 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('ALLOWED_EXTENSIONS does not include executable formats', function () {
|
||||
$constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS');
|
||||
expect($constant)->toBeArray();
|
||||
|
||||
$forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py'];
|
||||
foreach ($forbidden as $bad) {
|
||||
expect($constant)->not->toContain($bad);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Health;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
it('defaults to an enabled healthcheck when nothing is configured', function () {
|
||||
$database = new StandalonePostgresql;
|
||||
|
||||
expect($database->isHealthcheckEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
it('builds the compose healthcheck block from the model timing fields', function () {
|
||||
$database = new StandalonePostgresql([
|
||||
'health_check_interval' => 30,
|
||||
'health_check_timeout' => 7,
|
||||
'health_check_retries' => 4,
|
||||
'health_check_start_period' => 12,
|
||||
]);
|
||||
|
||||
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
|
||||
|
||||
expect($config)->toBe([
|
||||
'test' => ['CMD', 'pg_isready'],
|
||||
'interval' => '30s',
|
||||
'timeout' => '7s',
|
||||
'retries' => 4,
|
||||
'start_period' => '12s',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to safe defaults when timing fields are missing', function () {
|
||||
$database = new StandalonePostgresql;
|
||||
|
||||
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
|
||||
|
||||
expect($config['interval'])->toBe('15s')
|
||||
->and($config['timeout'])->toBe('5s')
|
||||
->and($config['retries'])->toBe(5)
|
||||
->and($config['start_period'])->toBe('5s');
|
||||
});
|
||||
|
||||
it('reports the healthcheck as disabled when the flag is false', function () {
|
||||
$database = new StandalonePostgresql(['health_check_enabled' => false]);
|
||||
|
||||
expect($database->isHealthcheckEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses distinct hash fragments for ambiguous healthcheck values', function () {
|
||||
$enabledDatabase = new StandalonePostgresql([
|
||||
'health_check_enabled' => true,
|
||||
'health_check_interval' => 5,
|
||||
'health_check_timeout' => 5,
|
||||
'health_check_retries' => 5,
|
||||
'health_check_start_period' => 5,
|
||||
]);
|
||||
|
||||
$disabledDatabase = new StandalonePostgresql([
|
||||
'health_check_enabled' => false,
|
||||
'health_check_interval' => 15,
|
||||
'health_check_timeout' => 5,
|
||||
'health_check_retries' => 5,
|
||||
'health_check_start_period' => 5,
|
||||
]);
|
||||
|
||||
$getHashFragment = function () {
|
||||
return $this->healthCheckConfigurationHash();
|
||||
};
|
||||
|
||||
expect($getHashFragment->call($enabledDatabase))
|
||||
->toBe('1|5|5|5|5')
|
||||
->not->toBe($getHashFragment->call($disabledDatabase))
|
||||
->and($getHashFragment->call($disabledDatabase))->toBe('0|15|5|5|5');
|
||||
});
|
||||
|
||||
it('does not mark configuration changed when health update authorization fails', function () {
|
||||
$database = new class
|
||||
{
|
||||
public ?string $config_hash = null;
|
||||
|
||||
public int $configurationChangedChecks = 0;
|
||||
|
||||
public function isConfigurationChanged(bool $save = false): bool
|
||||
{
|
||||
$this->configurationChangedChecks++;
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$component = new class extends Health
|
||||
{
|
||||
public array $dispatchedEvents = [];
|
||||
|
||||
public function authorize($ability, $arguments = [])
|
||||
{
|
||||
throw new AuthorizationException('This action is unauthorized.');
|
||||
}
|
||||
|
||||
public function dispatch($event, ...$params)
|
||||
{
|
||||
$this->dispatchedEvents[] = $event;
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$component->database = $database;
|
||||
$component->submit();
|
||||
|
||||
expect($database->configurationChangedChecks)->toBe(0)
|
||||
->and($component->dispatchedEvents)->toBe(['error']);
|
||||
});
|
||||
|
||||
it('toggles database healthcheck and marks configuration changed', function () {
|
||||
$database = new class
|
||||
{
|
||||
public ?string $config_hash = 'existing';
|
||||
|
||||
public bool $health_check_enabled = false;
|
||||
|
||||
public int $health_check_interval = 15;
|
||||
|
||||
public int $health_check_timeout = 5;
|
||||
|
||||
public int $health_check_retries = 5;
|
||||
|
||||
public int $health_check_start_period = 5;
|
||||
|
||||
public int $saveCalls = 0;
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->saveCalls++;
|
||||
}
|
||||
};
|
||||
|
||||
$component = new class extends Health
|
||||
{
|
||||
public array $dispatchedEvents = [];
|
||||
|
||||
public function authorize($ability, $arguments = [])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function dispatch($event, ...$params)
|
||||
{
|
||||
$this->dispatchedEvents[] = $event;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->database->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->database->save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$component->database = $database;
|
||||
$component->healthCheckEnabled = false;
|
||||
$component->healthCheckInterval = 15;
|
||||
$component->healthCheckTimeout = 5;
|
||||
$component->healthCheckRetries = 5;
|
||||
$component->healthCheckStartPeriod = 5;
|
||||
|
||||
$component->toggleHealthcheck();
|
||||
|
||||
expect($database->health_check_enabled)->toBeTrue()
|
||||
->and($database->saveCalls)->toBe(1)
|
||||
->and($component->dispatchedEvents)->toBe(['success', 'configurationChanged']);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Import;
|
||||
use App\Livewire\Project\Database\ImportForm;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
describe('container name validation', function () {
|
||||
test('isValidContainerName accepts valid container names', function () {
|
||||
@@ -45,43 +46,43 @@ describe('container name validation', function () {
|
||||
|
||||
describe('locked properties', function () {
|
||||
test('container property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'container');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'container');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('serverId property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'serverId');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'serverId');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resourceId property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'resourceId');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'resourceId');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resourceType property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'resourceType');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'resourceType');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resourceUuid property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'resourceUuid');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'resourceUuid');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resourceDbType property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'resourceDbType');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'resourceDbType');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
@@ -89,7 +90,7 @@ describe('locked properties', function () {
|
||||
|
||||
describe('server method uses team scoping', function () {
|
||||
test('server computed property calls ownedByCurrentTeam', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'server');
|
||||
$method = new ReflectionMethod(ImportForm::class, 'server');
|
||||
|
||||
// Extract the server method body
|
||||
$startLine = $method->getStartLine();
|
||||
@@ -102,9 +103,9 @@ describe('server method uses team scoping', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import component uses shared ValidationPatterns', function () {
|
||||
describe('ImportForm component uses shared ValidationPatterns', function () {
|
||||
test('runImport references ValidationPatterns for container validation', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'runImport');
|
||||
$method = new ReflectionMethod(ImportForm::class, 'runImport');
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
|
||||
@@ -114,7 +115,7 @@ describe('Import component uses shared ValidationPatterns', function () {
|
||||
});
|
||||
|
||||
test('restoreFromS3 references ValidationPatterns for container validation', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'restoreFromS3');
|
||||
$method = new ReflectionMethod(ImportForm::class, 'restoreFromS3');
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
it('declares explicit authorization on database import form controls', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
|
||||
|
||||
preg_match_all(
|
||||
'/<x-forms\.(button|input|select|checkbox|textarea)\b[^>]*>/s',
|
||||
$view,
|
||||
$matches,
|
||||
PREG_OFFSET_CAPTURE
|
||||
);
|
||||
|
||||
$missingAuthorization = collect($matches[0])
|
||||
->filter(fn (array $match): bool => ! str_contains($match[0], 'canGate=') || ! str_contains($match[0], 'canResource='))
|
||||
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($missingAuthorization)->toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Verifies the opt-in read/write replica split in config/database.php.
|
||||
* The config file is re-required under different putenv() states so the
|
||||
* env() calls re-evaluate, then the resulting pgsql array shape is asserted.
|
||||
*/
|
||||
|
||||
function loadDbConfig(): array
|
||||
{
|
||||
return require base_path('config/database.php');
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
foreach ([
|
||||
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
|
||||
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
|
||||
'DB_STICKY',
|
||||
] as $key) {
|
||||
putenv($key);
|
||||
}
|
||||
});
|
||||
|
||||
it('has no replica keys when DB_READ_HOST is unset', function () {
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql)
|
||||
->not->toHaveKey('read')
|
||||
->not->toHaveKey('write')
|
||||
->not->toHaveKey('sticky')
|
||||
->and($pgsql['driver'])->toBe('pgsql');
|
||||
});
|
||||
|
||||
it('enables the read/write split when DB_READ_HOST is set', function () {
|
||||
putenv('DB_READ_HOST=replica1, replica2');
|
||||
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql)
|
||||
->toHaveKey('read')
|
||||
->toHaveKey('write')
|
||||
->and($pgsql['read']['host'])->toBe(['replica1', 'replica2'])
|
||||
->and($pgsql['sticky'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('falls back to DB_* values for unset replica options', function () {
|
||||
putenv('DB_READ_HOST=replica1');
|
||||
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql['read']['port'])->toBe(env('DB_PORT', '5432'))
|
||||
->and($pgsql['read']['username'])->toBe(env('DB_USERNAME', 'coolify'))
|
||||
->and($pgsql['write']['host'])->toBe([env('DB_HOST', 'coolify-db')]);
|
||||
});
|
||||
|
||||
it('respects discrete replica overrides', function () {
|
||||
putenv('DB_READ_HOST=replica1');
|
||||
putenv('DB_READ_PORT=6432');
|
||||
putenv('DB_READ_USERNAME=reader');
|
||||
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql['read']['port'])->toBe('6432')
|
||||
->and($pgsql['read']['username'])->toBe('reader');
|
||||
});
|
||||
|
||||
it('disables sticky reads when DB_STICKY is false', function () {
|
||||
putenv('DB_READ_HOST=replica1');
|
||||
putenv('DB_STICKY=false');
|
||||
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql['sticky'])->toBeFalse();
|
||||
});
|
||||
@@ -1,17 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Configuration as ApplicationConfiguration;
|
||||
use App\Livewire\Project\Application\ServerStatusBadge;
|
||||
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
|
||||
use App\Livewire\Project\Database\Clickhouse\StatusInfo as ClickhouseStatusInfo;
|
||||
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
|
||||
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
|
||||
use App\Livewire\Project\Database\Import as DatabaseImport;
|
||||
use App\Livewire\Project\Database\ImportForm as DatabaseImportForm;
|
||||
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
|
||||
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
|
||||
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
|
||||
use App\Livewire\Project\Database\Mariadb\StatusInfo as MariadbStatusInfo;
|
||||
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
|
||||
use App\Livewire\Project\Database\Mongodb\StatusInfo as MongodbStatusInfo;
|
||||
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
|
||||
use App\Livewire\Project\Database\Mysql\StatusInfo as MysqlStatusInfo;
|
||||
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
|
||||
use App\Livewire\Project\Database\Postgresql\StatusInfo as PostgresqlStatusInfo;
|
||||
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
|
||||
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
|
||||
use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
|
||||
use App\Livewire\Project\Service\ResourceCard as ServiceResourceCard;
|
||||
use App\Livewire\Server\Sentinel;
|
||||
use App\Livewire\Server\Show;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -28,25 +48,159 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
dataset('ssl-aware-database-general-components', [
|
||||
dataset('database-general-forms-without-broadcasts', [
|
||||
// Status-derived display moved into a sibling StatusInfo component for each DB,
|
||||
// so the form itself takes no broadcast listeners and cannot clobber wire:dirty
|
||||
// by absorbing deferred wire:model values during a status-triggered roundtrip.
|
||||
RedisGeneral::class,
|
||||
PostgresqlGeneral::class,
|
||||
MysqlGeneral::class,
|
||||
MariadbGeneral::class,
|
||||
MongodbGeneral::class,
|
||||
RedisGeneral::class,
|
||||
PostgresqlGeneral::class,
|
||||
KeydbGeneral::class,
|
||||
DragonflyGeneral::class,
|
||||
ClickhouseGeneral::class,
|
||||
DatabaseImportForm::class,
|
||||
ServiceConfiguration::class,
|
||||
ApplicationConfiguration::class,
|
||||
]);
|
||||
|
||||
it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) {
|
||||
$component = app($componentClass);
|
||||
$listeners = $component->getListeners();
|
||||
dataset('database-status-info-components', [
|
||||
RedisStatusInfo::class,
|
||||
PostgresqlStatusInfo::class,
|
||||
MysqlStatusInfo::class,
|
||||
MariadbStatusInfo::class,
|
||||
MongodbStatusInfo::class,
|
||||
KeydbStatusInfo::class,
|
||||
DragonflyStatusInfo::class,
|
||||
ClickhouseStatusInfo::class,
|
||||
]);
|
||||
|
||||
expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh')
|
||||
->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh');
|
||||
})->with('ssl-aware-database-general-components');
|
||||
dataset('display-only-status-components', [
|
||||
RedisStatusInfo::class,
|
||||
PostgresqlStatusInfo::class,
|
||||
MysqlStatusInfo::class,
|
||||
MariadbStatusInfo::class,
|
||||
MongodbStatusInfo::class,
|
||||
KeydbStatusInfo::class,
|
||||
DragonflyStatusInfo::class,
|
||||
ClickhouseStatusInfo::class,
|
||||
DatabaseImport::class,
|
||||
ServiceResourceCard::class,
|
||||
ServerStatusBadge::class,
|
||||
]);
|
||||
|
||||
it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () {
|
||||
it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) {
|
||||
// Regression guard for coolify#6062 / #6354 / #9695:
|
||||
// Status broadcasts on the form would trigger a Livewire roundtrip that absorbs
|
||||
// deferred wire:model values into the snapshot — clobbering both the typed text
|
||||
// (resolved by the earlier refreshStatus fix) and the wire:dirty indicator.
|
||||
$listeners = resolveLivewireListeners(app($componentClass));
|
||||
|
||||
expect($listeners)
|
||||
->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
|
||||
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked")
|
||||
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged");
|
||||
})->with('database-general-forms-without-broadcasts');
|
||||
|
||||
/**
|
||||
* Resolve a Livewire component's listeners regardless of whether the subclass
|
||||
* exposes getListeners() publicly or only declares a $listeners array — the
|
||||
* HandlesEvents trait keeps getListeners() protected by default.
|
||||
*/
|
||||
function resolveLivewireListeners(object $component): array
|
||||
{
|
||||
$method = new ReflectionMethod($component, 'getListeners');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return (array) $method->invoke($component);
|
||||
}
|
||||
|
||||
it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) {
|
||||
// Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling
|
||||
// Livewire component so it can re-render on broadcasts without touching the form's DOM.
|
||||
$listeners = resolveLivewireListeners(app($componentClass));
|
||||
|
||||
expect($listeners)
|
||||
->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked");
|
||||
})->with('database-status-info-components');
|
||||
|
||||
it('keeps realtime status listeners on display-only components instead of form owners', function (string $componentClass) {
|
||||
$listeners = resolveLivewireListeners(app($componentClass));
|
||||
|
||||
expect($listeners)->not->toBeEmpty();
|
||||
})->with('display-only-status-components');
|
||||
|
||||
it('refreshes a service resource card without refreshing the service configuration form owner', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$service = Service::create([
|
||||
'name' => 'status-card-service',
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'docker_compose_raw' => 'services: {}',
|
||||
]);
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'web',
|
||||
'image' => 'nginx:latest',
|
||||
'status' => 'exited:unhealthy',
|
||||
]);
|
||||
$parameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
];
|
||||
|
||||
$component = Livewire::test(ServiceResourceCard::class, [
|
||||
'service' => $service,
|
||||
'resource' => $serviceApplication,
|
||||
'parameters' => $parameters,
|
||||
]);
|
||||
|
||||
$serviceApplication->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->call('refreshResource');
|
||||
|
||||
expect($component->instance()->resource->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('refreshes database import status from stored resource identity after the route context is gone', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandaloneMysql::create([
|
||||
'name' => 'import-status-mysql',
|
||||
'image' => 'mysql:8',
|
||||
'mysql_root_password' => 'password',
|
||||
'mysql_user' => 'coolify',
|
||||
'mysql_password' => 'password',
|
||||
'mysql_database' => 'coolify',
|
||||
'status' => 'exited:unhealthy',
|
||||
'is_log_drain_enabled' => false,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$component = app(DatabaseImport::class);
|
||||
$component->resourceId = $database->id;
|
||||
$component->resourceType = StandaloneMysql::class;
|
||||
|
||||
$database->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->refreshStatus();
|
||||
|
||||
expect($component->resourceStatus)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
@@ -67,7 +221,65 @@ it('reloads the mysql database model when refreshing so ssl controls follow the
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(MysqlGeneral::class, ['database' => $database])
|
||||
$component = Livewire::test(MysqlStatusInfo::class, ['database' => $database])
|
||||
->assertDontSee('Database should be stopped to change this settings.');
|
||||
|
||||
$database->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->call('refresh')
|
||||
->assertSee('Database should be stopped to change this settings.');
|
||||
});
|
||||
|
||||
it('does not clobber server form text inputs when sentinel restarts', function () {
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'persisted-server-name',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid])
|
||||
->set('sentinelToken', 'user-was-typing-this-token');
|
||||
|
||||
$component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]);
|
||||
|
||||
expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token');
|
||||
});
|
||||
|
||||
it('does not clobber server form text inputs when server validation completes', function () {
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'persisted-server-name',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Show::class, ['server_uuid' => $server->uuid])
|
||||
->set('name', 'user-was-typing-here')
|
||||
->set('ip', '203.0.113.42');
|
||||
|
||||
$component->call('handleServerValidated', ['serverUuid' => $server->uuid]);
|
||||
|
||||
expect($component->get('name'))->toBe('user-was-typing-here')
|
||||
->and($component->get('ip'))->toBe('203.0.113.42');
|
||||
});
|
||||
|
||||
it('shows the redis ssl gate hint after the sibling is refreshed', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$database = StandaloneRedis::create([
|
||||
'name' => 'test-redis',
|
||||
'image' => 'redis:7',
|
||||
'redis_password' => 'password',
|
||||
'redis_username' => 'default',
|
||||
'status' => 'exited:unhealthy',
|
||||
'enable_ssl' => true,
|
||||
'is_log_drain_enabled' => false,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(RedisStatusInfo::class, ['database' => $database])
|
||||
->assertDontSee('Database should be stopped to change this settings.');
|
||||
|
||||
$database->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\DeleteEnvironment;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
// Current team
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Another team
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('mount cannot load DeleteEnvironment with environment from another team', function () {
|
||||
Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentB->id]);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('mount can load DeleteEnvironment with own team environment', function () {
|
||||
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
|
||||
|
||||
expect($component->get('environmentName'))->toBe($this->environmentA->name);
|
||||
});
|
||||
|
||||
test('environment_id is locked and cannot be reassigned from the client', function () {
|
||||
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
|
||||
|
||||
try {
|
||||
$component->set('environment_id', $this->environmentB->id);
|
||||
$this->fail('Setting a #[Locked] property should have thrown.');
|
||||
} catch (CannotUpdateLockedPropertyException) {
|
||||
expect(true)->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
test('delete still removes an empty environment owned by the current team', function () {
|
||||
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id])
|
||||
->set('parameters', ['project_uuid' => $this->projectA->uuid]);
|
||||
|
||||
$component->call('delete');
|
||||
|
||||
expect(Environment::find($this->environmentA->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('delete cannot resolve a non-empty environment from another team', function () {
|
||||
// The team-scoped lookup must stay in the delete() path so the
|
||||
// "has defined resources" branch can never run for an environment
|
||||
// outside the caller's team.
|
||||
Application::factory()->create([
|
||||
'environment_id' => $this->environmentB->id,
|
||||
]);
|
||||
|
||||
$teamScopedLookup = fn () => Environment::ownedByCurrentTeam()
|
||||
->findOrFail($this->environmentB->id);
|
||||
|
||||
expect($teamScopedLookup)->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
||||
test('team scoped lookup permits own team environment', function () {
|
||||
// Positive case so the cross-team check above cannot pass merely
|
||||
// because the helper itself is broken.
|
||||
$found = Environment::ownedByCurrentTeam()->findOrFail($this->environmentA->id);
|
||||
|
||||
expect($found->id)->toBe($this->environmentA->id);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
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\Testing\TestResponse;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$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]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
function showDeployment(string $status): TestResponse
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => test()->application->id,
|
||||
'deployment_uuid' => 'deploy-scroll-'.$status,
|
||||
'server_id' => test()->server->id,
|
||||
'status' => $status,
|
||||
'logs' => json_encode([[
|
||||
'command' => null,
|
||||
'output' => 'log line for '.$status,
|
||||
'type' => 'stdout',
|
||||
'timestamp' => now()->toISOString(),
|
||||
'hidden' => false,
|
||||
'batch' => 1,
|
||||
'order' => 1,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
return test()->get(route('project.application.deployment.show', [
|
||||
'project_uuid' => test()->project->uuid,
|
||||
'environment_uuid' => test()->environment->uuid,
|
||||
'application_uuid' => test()->application->uuid,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
]));
|
||||
}
|
||||
|
||||
it('does not enable follow mode for a finished deployment', function () {
|
||||
$response = showDeployment(ApplicationDeploymentStatus::FINISHED->value);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('alwaysScroll: false', false);
|
||||
$response->assertDontSee('alwaysScroll: true', false);
|
||||
});
|
||||
|
||||
it('enables follow mode for an in-progress deployment', function () {
|
||||
$response = showDeployment(ApplicationDeploymentStatus::IN_PROGRESS->value);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('alwaysScroll: true', false);
|
||||
});
|
||||
|
||||
it('scopes scroll teardown to the component so a stale loop cannot leak across deployments', function () {
|
||||
$content = showDeployment(ApplicationDeploymentStatus::FINISHED->value)->getContent();
|
||||
|
||||
// Alpine destroy() tears the scroll loop down on wire:navigate away.
|
||||
expect($content)->toContain('destroy()')
|
||||
->toContain('cancelScrollLoop()')
|
||||
// Container lookup is component-scoped, not a global getElementById.
|
||||
->toContain("this.\$root.querySelector('#logsContainer')")
|
||||
->not->toContain("document.getElementById('logsContainer')")
|
||||
// morph.updated hook only acts on this component's own DOM.
|
||||
->toContain('this.$root.contains(el)')
|
||||
// Global Livewire hook is unregistered when Alpine tears down.
|
||||
->toContain('morphUpdatedCleanup: null')
|
||||
->toContain("this.morphUpdatedCleanup = Livewire.hook('morph.updated'")
|
||||
->toContain("typeof this.morphUpdatedCleanup === 'function'")
|
||||
->toContain('this.morphUpdatedCleanup()')
|
||||
// Continuation timeout is tracked so it can be cancelled.
|
||||
->toContain('scrollTimeout');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$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]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders deployment logs in a full-height layout', function () {
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $this->application->id,
|
||||
'deployment_uuid' => 'deploy-layout-test',
|
||||
'server_id' => $this->server->id,
|
||||
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
||||
'logs' => json_encode([
|
||||
[
|
||||
'command' => null,
|
||||
'output' => 'rolling update started',
|
||||
'type' => 'stdout',
|
||||
'timestamp' => now()->toISOString(),
|
||||
'hidden' => false,
|
||||
'batch' => 1,
|
||||
'order' => 1,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.application.deployment.show', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('rolling update started');
|
||||
$response->assertSee('flex h-[calc(100vh-10rem)] min-h-40 flex-col overflow-hidden', false);
|
||||
$response->assertSee('flex flex-1 min-h-0 flex-col overflow-hidden', false);
|
||||
$response->assertSee('mt-4 flex flex-1 min-h-0 flex-col overflow-hidden', false);
|
||||
$response->assertSee('flex min-h-0 flex-col w-full overflow-hidden bg-white', false);
|
||||
$response->assertSee('flex min-h-40 flex-1 flex-col overflow-y-auto p-2 px-4 scrollbar', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('max-h-[30rem]');
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
it('positions the deployments indicator from the sidebar collapsed state', function () {
|
||||
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
|
||||
$layoutView = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($indicatorView)
|
||||
->toContain('transition-[left] duration-200')
|
||||
->toContain(":class=\"collapsed ? 'lg:left-16' : 'lg:left-56'\"")
|
||||
->not->toContain('fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4');
|
||||
|
||||
expect($layoutView)
|
||||
->toContain('<div x-data="{')
|
||||
->toContain('<livewire:deployments-indicator />');
|
||||
|
||||
expect(strpos($layoutView, '<div x-data="{'))
|
||||
->toBeLessThan(strpos($layoutView, '<livewire:deployments-indicator />'));
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
test('deprecated docker compose application endpoint is not registered', function () {
|
||||
$routes = collect(Route::getRoutes()->getRoutes())
|
||||
->filter(fn ($route) => in_array('POST', $route->methods(), true))
|
||||
->filter(fn ($route) => $route->uri() === 'api/v1/applications/dockercompose');
|
||||
|
||||
expect($routes)->toBeEmpty();
|
||||
|
||||
$this->postJson('/api/v1/applications/dockercompose')->assertNotFound();
|
||||
});
|
||||
|
||||
test('custom docker compose services endpoint remains registered', function () {
|
||||
$route = collect(Route::getRoutes()->getRoutes())
|
||||
->first(fn ($route) => in_array('POST', $route->methods(), true) && $route->uri() === 'api/v1/services');
|
||||
|
||||
expect($route)->not->toBeNull()
|
||||
->and($route->getActionName())->toBe(ServicesController::class.'@create_service');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Settings\Index;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Model::unguarded(function () {
|
||||
$this->rootTeam = Team::find(0) ?? Team::create(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]);
|
||||
if (! Server::find(0)) {
|
||||
Server::factory()->create(['id' => 0, 'team_id' => $this->rootTeam->id]);
|
||||
}
|
||||
if (! InstanceSettings::find(0)) {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
}
|
||||
});
|
||||
Once::flush();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->rootTeam->members()->attach($this->user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->rootTeam->id]]);
|
||||
});
|
||||
|
||||
test('dev_helper_version rejects values outside Docker tag grammar on save', function () {
|
||||
$invalid = [
|
||||
'latest with spaces',
|
||||
'a$b',
|
||||
'a`b',
|
||||
'a|b',
|
||||
'a;b',
|
||||
'a&b',
|
||||
'a>b',
|
||||
'a<b',
|
||||
"a\nb",
|
||||
'.bad',
|
||||
'-rm',
|
||||
];
|
||||
|
||||
foreach ($invalid as $payload) {
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', $payload)
|
||||
->call('instantSave')
|
||||
->assertHasErrors(['dev_helper_version']);
|
||||
}
|
||||
|
||||
expect(InstanceSettings::find(0)->dev_helper_version)->toBeNull();
|
||||
});
|
||||
|
||||
test('dev_helper_version accepts valid docker tag formats', function () {
|
||||
$valid = ['1.0.12', 'latest', 'dev', 'dev-branch_2', 'v1.2.3-rc1', '1_0_0'];
|
||||
|
||||
foreach ($valid as $tag) {
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', $tag)
|
||||
->call('instantSave')
|
||||
->assertHasNoErrors(['dev_helper_version']);
|
||||
|
||||
expect(InstanceSettings::find(0)->fresh()->dev_helper_version)->toBe($tag);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildHelperImage refuses when non-dev environment', function () {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', 'latest')
|
||||
->call('buildHelperImage')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
test('buildHelperImage refuses previously stored invalid version', function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
$settings = InstanceSettings::find(0);
|
||||
$settings->forceFill(['dev_helper_version' => 'bad value'])->saveQuietly();
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('buildHelperImage')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Database\Seeders\DevelopmentRailpackExamplesSeeder;
|
||||
use Database\Seeders\GithubAppSeeder;
|
||||
use Database\Seeders\PrivateKeySeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Database\Seeders\ServerSeeder;
|
||||
use Database\Seeders\StandaloneDockerSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedRailpackExamplePrerequisites(): void
|
||||
{
|
||||
test()->seed([
|
||||
UserSeeder::class,
|
||||
TeamSeeder::class,
|
||||
PrivateKeySeeder::class,
|
||||
ServerSeeder::class,
|
||||
ProjectSeeder::class,
|
||||
StandaloneDockerSeeder::class,
|
||||
GithubAppSeeder::class,
|
||||
]);
|
||||
}
|
||||
|
||||
it('can seed the railpack examples directly on a clean development database', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
expect(Team::query()->find(0))->not->toBeNull();
|
||||
expect(PrivateKey::query()->find(1))->not->toBeNull();
|
||||
expect(Server::query()->find(0))->not->toBeNull();
|
||||
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
|
||||
expect(GithubApp::query()->find(0))->not->toBeNull();
|
||||
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
|
||||
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
});
|
||||
|
||||
it('seeds the railpack examples in development mode', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
seedRailpackExamplePrerequisites();
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
$project = Project::query()
|
||||
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
|
||||
->first();
|
||||
|
||||
expect($project)
|
||||
->not->toBeNull()
|
||||
->and($project->name)->toBe('Railpack Examples')
|
||||
->and($project->environments)->toHaveCount(1)
|
||||
->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID);
|
||||
|
||||
$applications = $project->applications()->with('settings')->orderBy('uuid')->get();
|
||||
|
||||
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
|
||||
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
|
||||
|
||||
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
|
||||
expect($applications->every(
|
||||
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['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');
|
||||
|
||||
expect($nestjs)
|
||||
->not->toBeNull()
|
||||
->and($nestjs->base_directory)->toBe('/node/nestjs')
|
||||
->and($nestjs->ports_exposes)->toBe('3000')
|
||||
->and($nestjs->build_command)->toBe('npm run build')
|
||||
->and($nestjs->start_command)->toBe('npm run start:prod')
|
||||
->and($nestjs->settings->is_static)->toBeFalse();
|
||||
|
||||
expect($angularStatic)
|
||||
->not->toBeNull()
|
||||
->and($angularStatic->publish_directory)->toBe('/dist/static/browser')
|
||||
->and($angularStatic->ports_exposes)->toBe('80')
|
||||
->and($angularStatic->settings->is_static)->toBeTrue()
|
||||
->and($angularStatic->settings->is_spa)->toBeTrue();
|
||||
|
||||
expect($eleventyStatic)
|
||||
->not->toBeNull()
|
||||
->and($eleventyStatic->publish_directory)->toBe('/_site')
|
||||
->and($eleventyStatic->settings->is_static)->toBeTrue()
|
||||
->and($eleventyStatic->settings->is_spa)->toBeFalse();
|
||||
|
||||
expect($pythonFlask)
|
||||
->not->toBeNull()
|
||||
->and($pythonFlask->ports_exposes)->toBe('5000')
|
||||
->and($pythonFlask->start_command)->toBe('flask run --host=0.0.0.0 --port=5000');
|
||||
|
||||
expect($goGin)
|
||||
->not->toBeNull()
|
||||
->and($goGin->ports_exposes)->toBe('3000');
|
||||
|
||||
expect($rust)
|
||||
->not->toBeNull()
|
||||
->and($rust->ports_exposes)->toBe('8000');
|
||||
});
|
||||
|
||||
it('skips the railpack examples outside development mode', function () {
|
||||
config()->set('app.env', 'testing');
|
||||
|
||||
seedRailpackExamplePrerequisites();
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse();
|
||||
expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('is idempotent when run multiple times', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
seedRailpackExamplePrerequisites();
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
$project = Project::query()
|
||||
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
|
||||
->first();
|
||||
|
||||
expect($project)->not->toBeNull();
|
||||
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('email verification hash', function () {
|
||||
test('sha256 hash is accepted and marks the user verified', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'verify-me@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get($url)->assertRedirect();
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('legacy sha1 hash is rejected', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'legacy-sha1@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => sha1($user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get($url)->assertStatus(403);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('tampered signature is rejected', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'tampered@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$tampered = $url.'x';
|
||||
|
||||
$this->actingAs($user)->get($tampered)->assertStatus(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('rejects environment variable keys Docker cannot represent in the add form', function () {
|
||||
Livewire::test(Add::class)
|
||||
->set('key', 'BAD=KEY')
|
||||
->set('value', 'value')
|
||||
->call('submit')
|
||||
->assertHasErrors(['key' => 'regex']);
|
||||
});
|
||||
|
||||
it('allows Docker-compatible environment variable keys in the add form', function (string $key) {
|
||||
Livewire::test(Add::class)
|
||||
->set('key', $key)
|
||||
->set('value', 'value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('saveKey', function ($event, array $data) use ($key) {
|
||||
return data_get($data, 'key') === $key || data_get($data, '0.key') === $key;
|
||||
});
|
||||
})->with([
|
||||
'starts with digit' => '1BAD',
|
||||
'hyphen' => 'BAD-KEY',
|
||||
'dot' => 'node.name',
|
||||
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
|
||||
]);
|
||||
|
||||
it('trims surrounding whitespace in environment variable keys in the add form', function () {
|
||||
Livewire::test(Add::class)
|
||||
->set('key', ' node.name ')
|
||||
->set('value', 'value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('saveKey', function ($event, array $data) {
|
||||
return data_get($data, 'key') === 'node.name' || data_get($data, '0.key') === 'node.name';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake([
|
||||
'discord.com/*' => Http::response([], 204),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects feedback with missing content', function () {
|
||||
$response = $this->postJson('/api/feedback', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with content too short', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => 'short']);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with content too long', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with non-string content', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('accepts valid feedback and forwards to discord with mentions disabled', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This is a valid feedback message for testing purposes.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Feedback sent.']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://discord.com/api/webhooks/test'
|
||||
&& $request['content'] === 'This is a valid feedback message for testing purposes.'
|
||||
&& $request['allowed_mentions'] === ['parse' => []];
|
||||
});
|
||||
});
|
||||
|
||||
it('does not forward to discord when webhook url is not configured', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', null);
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This is a valid feedback message for testing purposes.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('throttles feedback endpoint after 3 requests per minute', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', null);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => "Valid feedback message number {$i} for testing.",
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This fourth request should be throttled.',
|
||||
]);
|
||||
$response->assertStatus(429);
|
||||
});
|
||||
|
||||
it('disables discord mention parsing regardless of content', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request['allowed_mentions'] === ['parse' => []];
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,20 @@ describe('GetLogs Livewire action validation', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLogs stream polling', function () {
|
||||
test('streaming logs polls when log panel is not collapsible', function () {
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $this->server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'coolify-sentinel',
|
||||
'collapsible' => false,
|
||||
])
|
||||
->assertDontSeeHtml('wire:poll.2000ms="getLogs(true)"')
|
||||
->call('toggleStreamLogs')
|
||||
->assertSeeHtml('wire:poll.2000ms="getLogs(true)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLogs container name injection payloads are blocked by validation', function () {
|
||||
test('newline injection payload is rejected', function () {
|
||||
// The exact PoC payload from the advisory
|
||||
|
||||
@@ -5,8 +5,10 @@ use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -64,6 +66,21 @@ function fakeGithubHttp(array $repositories): void
|
||||
]);
|
||||
}
|
||||
|
||||
function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
|
||||
{
|
||||
$rsaKey = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($rsaKey, $pemKey);
|
||||
|
||||
return PrivateKey::create([
|
||||
'name' => 'Test Key '.$team->id,
|
||||
'private_key' => $pemKey,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
}
|
||||
|
||||
describe('GitHub Private Repository Component', function () {
|
||||
test('loadRepositories fetches and displays repositories', function () {
|
||||
$repos = [
|
||||
@@ -81,6 +98,103 @@ describe('GitHub Private Repository Component', function () {
|
||||
->assertSet('selected_repository_id', 1);
|
||||
});
|
||||
|
||||
test('loadRepositories rejects a github app owned by another team', function () {
|
||||
$victimTeam = Team::factory()->create();
|
||||
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
|
||||
$victimGithubApp = GithubApp::create([
|
||||
'name' => 'Victim GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 54321,
|
||||
'installation_id' => 98765,
|
||||
'client_id' => 'victim-client-id',
|
||||
'client_secret' => 'victim-client-secret',
|
||||
'webhook_secret' => 'victim-webhook-secret',
|
||||
'private_key_id' => $victimPrivateKey->id,
|
||||
'team_id' => $victimTeam->id,
|
||||
'is_public' => false,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->call('loadRepositories', $victimGithubApp->id)
|
||||
)->toThrow(ModelNotFoundException::class);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('mount lists another teams system wide github app', function () {
|
||||
$victimTeam = Team::factory()->create();
|
||||
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
|
||||
$systemWideGithubApp = GithubApp::create([
|
||||
'name' => 'System Wide GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 54321,
|
||||
'installation_id' => 98765,
|
||||
'client_id' => 'system-client-id',
|
||||
'client_secret' => 'system-client-secret',
|
||||
'webhook_secret' => 'system-webhook-secret',
|
||||
'private_key_id' => $victimPrivateKey->id,
|
||||
'team_id' => $victimTeam->id,
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']);
|
||||
|
||||
expect($component->get('github_apps')->pluck('id')->all())
|
||||
->toContain($this->githubApp->id)
|
||||
->toContain($systemWideGithubApp->id);
|
||||
});
|
||||
|
||||
test('loadRepositories can use another teams system wide github app', function () {
|
||||
$victimTeam = Team::factory()->create();
|
||||
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
|
||||
$systemWideGithubApp = GithubApp::create([
|
||||
'name' => 'System Wide GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 54321,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'system-client-id',
|
||||
'client_secret' => 'system-client-secret',
|
||||
'webhook_secret' => 'system-webhook-secret',
|
||||
'private_key_id' => $victimPrivateKey->id,
|
||||
'team_id' => $victimTeam->id,
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'system-repo', 'owner' => ['login' => 'testuser']],
|
||||
];
|
||||
|
||||
fakeGithubHttp($repos);
|
||||
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->call('loadRepositories', $systemWideGithubApp->id)
|
||||
->assertSet('current_step', 'repository')
|
||||
->assertSet('total_repositories_count', 1)
|
||||
->assertSet('selected_repository_id', 1);
|
||||
});
|
||||
|
||||
test('github installation token is not stored as public component state', function () {
|
||||
expect((new ReflectionClass(GithubPrivateRepository::class))->hasProperty('token'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('selected github app id cannot be tampered with from the client', function () {
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->set('selected_github_app_id', $this->githubApp->id);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
|
||||
test('loadRepositories can be called again to refresh the repository list', function () {
|
||||
$initialRepos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Livewire\Source\Github\Change;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -19,9 +20,45 @@ beforeEach(function () {
|
||||
// Set current team
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
InstanceSettings::forceCreate([
|
||||
'id' => 0,
|
||||
'fqdn' => null,
|
||||
'public_ipv4' => null,
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
function validPrivateKey(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
describe('GitHub Source Change Component', function () {
|
||||
test('all github app form controls declare explicit authorization', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
|
||||
|
||||
preg_match_all(
|
||||
'/<x-forms\.(button|input|select|checkbox)\b(?![^>]*\bcanGate=)[^>]*>/s',
|
||||
$view,
|
||||
$matches,
|
||||
PREG_OFFSET_CAPTURE
|
||||
);
|
||||
|
||||
$missingAuthorization = collect($matches[0])
|
||||
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
|
||||
->all();
|
||||
|
||||
expect($missingAuthorization)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('can mount with newly created github app with null app_id', function () {
|
||||
// Create a GitHub app without app_id (simulating a newly created source)
|
||||
$githubApp = GithubApp::create([
|
||||
@@ -47,10 +84,69 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSet('privateKeyId', null);
|
||||
});
|
||||
|
||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'fqdn' => null,
|
||||
'public_ipv4' => null,
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
|
||||
$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()
|
||||
->assertSet('webhook_endpoint', 'http://localhost:8000');
|
||||
});
|
||||
|
||||
test('custom webhook endpoint is selected explicitly with a checkbox', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'fqdn' => 'http://staging.example.com',
|
||||
'public_ipv4' => '84.1.202.183',
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
|
||||
$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()
|
||||
->assertSet('use_custom_webhook_endpoint', false)
|
||||
->set('custom_webhook_endpoint', 'https://staging.example.com')
|
||||
->set('use_custom_webhook_endpoint', true)
|
||||
->assertSet('webhook_endpoint', 'http://staging.example.com')
|
||||
->assertSet('custom_webhook_endpoint', 'https://staging.example.com')
|
||||
->assertSet('use_custom_webhook_endpoint', true)
|
||||
->assertSee('Use custom webhook endpoint')
|
||||
->assertSee('Selected endpoint')
|
||||
->assertSee('Custom endpoint')
|
||||
->assertSee('createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint');
|
||||
});
|
||||
|
||||
test('can mount with fully configured github app', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-private-key-content',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
@@ -84,7 +180,7 @@ describe('GitHub Source Change Component', function () {
|
||||
test('can update github app from null to valid values', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-private-key-content',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
@@ -157,8 +253,8 @@ describe('GitHub Source Change Component', function () {
|
||||
|
||||
// Verify the database was updated
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->app_id)->toBe('1234567890');
|
||||
expect($githubApp->installation_id)->toBe('1234567890');
|
||||
expect($githubApp->app_id)->toBe(1234567890);
|
||||
expect($githubApp->installation_id)->toBe(1234567890);
|
||||
});
|
||||
|
||||
test('checkPermissions validates required fields', function () {
|
||||
@@ -179,6 +275,8 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSuccessful()
|
||||
->call('checkPermissions')
|
||||
->assertDispatched('error', function ($event, $message) {
|
||||
$message = is_array($message) ? implode(' ', $message) : $message;
|
||||
|
||||
return str_contains($message, 'App ID') && str_contains($message, 'Private Key');
|
||||
});
|
||||
});
|
||||
@@ -202,6 +300,8 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSuccessful()
|
||||
->call('checkPermissions')
|
||||
->assertDispatched('error', function ($event, $message) {
|
||||
$message = is_array($message) ? implode(' ', $message) : $message;
|
||||
|
||||
return str_contains($message, 'Private Key not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -446,3 +446,74 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error responses do not leak exception details', function () {
|
||||
test('locations endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
expect($response->getContent())->not->toContain('/var/secret/path');
|
||||
});
|
||||
|
||||
test('server-types endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('images endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/images*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('ssh-keys endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('invitation link login', function () {
|
||||
test('does not auto-verify the email address', function () {
|
||||
$team = Team::factory()->create();
|
||||
$password = 'test-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('still logs the user in', function () {
|
||||
$team = Team::factory()->create();
|
||||
$password = 'test-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee2@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
|
||||
expect(auth()->id())->toBe($user->id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function configurationCheckerApplication(Environment $environment, array $attributes = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'build_command' => 'npm run build',
|
||||
'fqdn' => 'https://example.com',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function markConfigurationCheckerApplicationDeployed(Application $application): void
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
}
|
||||
|
||||
it('does not render the notification for preview deployment toggles', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertDontSee('The latest deployment is not using the current configuration')
|
||||
->assertSet('isConfigurationChanged', false);
|
||||
});
|
||||
|
||||
it('renders the changed configuration labels', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Build command')
|
||||
->assertSee('A rebuild is required.');
|
||||
});
|
||||
|
||||
it('refreshes configuration changes when the event is received', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSet('isConfigurationChanged', false)
|
||||
->assertDontSee('The latest configuration has not been applied');
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Build command');
|
||||
});
|
||||
|
||||
it('refreshes stale modal configuration diff before opening changes', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('Build command')
|
||||
->assertDontSee('Start command');
|
||||
|
||||
$application->update([
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
]);
|
||||
|
||||
$component
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('Start command')
|
||||
->assertDontSee('Build command');
|
||||
});
|
||||
|
||||
it('does not render environment variable secret values', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'old-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
markConfigurationCheckerApplicationDeployed($application->refresh());
|
||||
|
||||
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('••••••••')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('old-secret')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
|
||||
it('renders added environment variables as set without exposing secret values', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'new-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('From')
|
||||
->assertSee('-')
|
||||
->assertSee('To')
|
||||
->assertSee('••••••••')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Advanced;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createApplicationForAdvancedStopGracePeriodTest(): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::create([
|
||||
'name' => 'stop-grace-period-test-app',
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
|
||||
'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
});
|
||||
|
||||
it('saves a valid stop grace period', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('stopGracePeriod', '300')
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBe(300);
|
||||
});
|
||||
|
||||
it('dispatches configuration changed when advanced settings are saved', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('includeSourceCommitInBuild', true)
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
|
||||
it('clears the stop grace period when submitted empty', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
$application->settings->update(['stop_grace_period' => 300]);
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application->fresh()])
|
||||
->set('stopGracePeriod', '')
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid stop grace periods', function (string $value, string $rule) {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('stopGracePeriod', $value)
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasErrors(['stopGracePeriod' => [$rule]]);
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBeNull();
|
||||
})->with([
|
||||
'below minimum' => ['0', 'min'],
|
||||
'above maximum' => [(string) (MAX_STOP_GRACE_PERIOD_SECONDS + 1), 'max'],
|
||||
'malformed integer' => ['10abc', 'integer'],
|
||||
'decimal' => ['1.9', 'integer'],
|
||||
]);
|
||||
|
||||
it('uses one second deployment timeout in local only when stop grace period is unset', function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
$setting = new ApplicationSetting;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(MIN_STOP_GRACE_PERIOD_SECONDS);
|
||||
|
||||
$setting->stop_grace_period = 10;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(10);
|
||||
});
|
||||
|
||||
it('uses default deployment timeout outside local when stop grace period is unset', function () {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
$setting = new ApplicationSetting;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
describe('PublicGitRepository port handling for railpack', function () {
|
||||
test('switching to railpack resets port to 3000 when not static', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('build_pack', 'dockerfile')
|
||||
->assertSet('port', 3000)
|
||||
->set('build_pack', 'railpack')
|
||||
->assertSet('port', 3000);
|
||||
});
|
||||
|
||||
test('switching to railpack preserves port when isStatic is true', function () {
|
||||
$component = Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('isStatic', true)
|
||||
->call('instantSave');
|
||||
|
||||
// After instantSave with isStatic=true, port becomes 80
|
||||
$component->assertSet('port', 80);
|
||||
|
||||
// Switching from nixpacks to railpack should NOT clobber port back to 3000
|
||||
$component->set('build_pack', 'railpack')
|
||||
->assertSet('port', 80);
|
||||
});
|
||||
|
||||
test('switching to static sets port to 80 and disables show_is_static', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('build_pack', 'static')
|
||||
->assertSet('port', 80)
|
||||
->assertSet('isStatic', false)
|
||||
->assertSet('show_is_static', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('General view railpack helper text', function () {
|
||||
beforeEach(function () {
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
|
||||
});
|
||||
|
||||
test('railpack app shows railpack.json helper text and not nixpacks.toml', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'railpack',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSee('railpack.json')
|
||||
->assertDontSee('nixpacks.toml');
|
||||
});
|
||||
|
||||
test('nixpacks app shows nixpacks.toml helper text and not railpack.json', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSee('nixpacks.toml')
|
||||
->assertDontSee('railpack.json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->where('id', 0)->delete();
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings(['is_mcp_server_enabled' => true]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function mcpPost(array $payload, ?string $token = null)
|
||||
{
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json, text/event-stream',
|
||||
];
|
||||
if ($token) {
|
||||
$headers['Authorization'] = 'Bearer '.$token;
|
||||
}
|
||||
|
||||
return test()->withHeaders($headers)->postJson('/mcp', $payload);
|
||||
}
|
||||
|
||||
function mcpListTools(string $token)
|
||||
{
|
||||
return mcpPost([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/list',
|
||||
'params' => (object) [],
|
||||
], $token);
|
||||
}
|
||||
|
||||
function mcpCallTool(string $token, string $name, array $arguments = [])
|
||||
{
|
||||
return mcpPost([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $name,
|
||||
'arguments' => (object) $arguments,
|
||||
],
|
||||
], $token);
|
||||
}
|
||||
|
||||
function mcpToolJson($response): array
|
||||
{
|
||||
return json_decode($response->json('result.content.0.text'), true);
|
||||
}
|
||||
|
||||
test('MCP endpoint returns 404 when the instance setting is disabled', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
|
||||
Once::flush();
|
||||
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('MCP endpoint rejects unauthenticated requests', function () {
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('MCP endpoint lists tools for an authenticated token', function () {
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpListTools($token);
|
||||
$response->assertOk();
|
||||
|
||||
$toolNames = collect($response->json('result.tools'))->pluck('name')->all();
|
||||
expect($toolNames)->toContain(
|
||||
'get_infrastructure_overview',
|
||||
'list_servers',
|
||||
'get_server',
|
||||
'list_projects',
|
||||
'list_applications',
|
||||
'get_application',
|
||||
'list_databases',
|
||||
'get_database',
|
||||
'list_services',
|
||||
'get_service',
|
||||
);
|
||||
expect($toolNames)->not->toContain('get_resource_status');
|
||||
});
|
||||
|
||||
test('list_projects returns summary + pagination scoped to the token team', function () {
|
||||
$project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]);
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects');
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
|
||||
expect($body)->toHaveKey('data');
|
||||
expect($body)->toHaveKey('_pagination');
|
||||
expect($body['_pagination']['total'])->toBe(1);
|
||||
expect($body['_pagination']['per_page'])->toBe(50);
|
||||
expect($body['_pagination'])->not->toHaveKey('next');
|
||||
|
||||
$uuids = collect($body['data'])->pluck('uuid')->all();
|
||||
$names = collect($body['data'])->pluck('name')->all();
|
||||
expect($uuids)->toContain($project->uuid);
|
||||
expect($names)->not->toContain('Theirs');
|
||||
expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']);
|
||||
});
|
||||
|
||||
test('list_projects paginates with per_page cap at 100', function () {
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]);
|
||||
}
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]);
|
||||
$body = mcpToolJson($response);
|
||||
|
||||
expect($body['_pagination']['total'])->toBe(3);
|
||||
expect($body['_pagination']['total_pages'])->toBe(2);
|
||||
expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]);
|
||||
expect($body['data'])->toHaveCount(2);
|
||||
|
||||
// Verify max cap
|
||||
$capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]);
|
||||
$cappedBody = mcpToolJson($capped);
|
||||
expect($cappedBody['_pagination']['per_page'])->toBe(100);
|
||||
});
|
||||
|
||||
test('get_infrastructure_overview returns counts', function () {
|
||||
Project::create(['name' => 'One', 'team_id' => $this->team->id]);
|
||||
Project::create(['name' => 'Two', 'team_id' => $this->team->id]);
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'get_infrastructure_overview');
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
expect($body)->toHaveKey('data');
|
||||
expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
|
||||
expect($body['data']['counts']['projects'])->toBe(2);
|
||||
expect($body['data']['projects'])->toHaveCount(2);
|
||||
expect($body['data']['projects'][0])->toHaveKey('counts');
|
||||
});
|
||||
|
||||
test('get_server scrubs sensitive nested data and exposes connection_timeout', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
// creating hook auto-generates a sentinel_token; bump connection_timeout
|
||||
// via saveQuietly to avoid triggering restartSentinel.
|
||||
$server->settings->forceFill(['connection_timeout' => 42])->saveQuietly();
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]);
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
$raw = json_encode($body);
|
||||
|
||||
expect($raw)->not->toContain('sentinel_token');
|
||||
expect($raw)->not->toContain('"team_id"');
|
||||
expect($raw)->not->toContain('"private_key_id"');
|
||||
expect($body['data']['connection_timeout'])->toBe(42);
|
||||
expect($body['data']['uuid'])->toBe($server->uuid);
|
||||
});
|
||||
|
||||
test('tool calls fail when the token lacks the read ability', function () {
|
||||
$token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects');
|
||||
$response->assertOk();
|
||||
|
||||
expect($response->json('result.isError'))->toBeTrue();
|
||||
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings([
|
||||
'is_mcp_server_enabled' => false,
|
||||
'is_api_enabled' => true,
|
||||
]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function makeRootMcpToken(User $user): string
|
||||
{
|
||||
$token = $user->createToken('mcp-root', ['root']);
|
||||
DB::table('personal_access_tokens')
|
||||
->where('id', $token->accessToken->id)
|
||||
->update(['team_id' => '0']);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string
|
||||
{
|
||||
$token = $user->createToken('mcp-write', $abilities);
|
||||
DB::table('personal_access_tokens')
|
||||
->where('id', $token->accessToken->id)
|
||||
->update(['team_id' => (string) $team->id]);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
test('POST /api/v1/mcp/enable enables MCP server with root token', function () {
|
||||
$token = makeRootMcpToken($this->user);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['message' => 'MCP server enabled.']);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST /api/v1/mcp/disable disables MCP server with root token', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
|
||||
$token = makeRootMcpToken($this->user);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/disable');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['message' => 'MCP server disabled.']);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('non-root token cannot enable MCP server', function () {
|
||||
$token = makeNonRootMcpToken($this->user, $this->team);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('non-root token cannot disable MCP server', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
|
||||
$token = makeNonRootMcpToken($this->user, $this->team);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/disable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('unauthenticated request to /api/v1/mcp/enable returns 401', function () {
|
||||
$response = test()->postJson('/api/v1/mcp/enable');
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('read-only token cannot toggle MCP server (lacks write ability)', function () {
|
||||
$token = makeNonRootMcpToken($this->user, $this->team, ['read']);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
@@ -299,6 +299,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
|
||||
'inject_build_args_to_dockerfile' => true,
|
||||
'include_source_commit_in_build' => true,
|
||||
'docker_images_to_keep' => 5,
|
||||
'stop_grace_period' => 300,
|
||||
]);
|
||||
|
||||
expect($setting->exists)->toBeTrue();
|
||||
@@ -309,6 +310,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
|
||||
expect($setting->custom_internal_name)->toBe('my-custom-app');
|
||||
expect($setting->is_spa)->toBeTrue();
|
||||
expect($setting->docker_images_to_keep)->toBe(5);
|
||||
expect($setting->stop_grace_period)->toBe(300);
|
||||
});
|
||||
|
||||
it('creates ServerSetting with all fillable attributes', function () {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\New\GithubPrivateRepository;
|
||||
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
describe('new application buildpack defaults', function () {
|
||||
test('github app repository flow defaults to nixpacks', function () {
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->assertSet('build_pack', 'nixpacks');
|
||||
});
|
||||
|
||||
test('deploy key repository flow defaults to nixpacks', function () {
|
||||
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
|
||||
->assertSet('build_pack', 'nixpacks');
|
||||
});
|
||||
|
||||
test('public repository flow defaults to nixpacks and lists railpack second', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->assertSet('build_pack', 'nixpacks');
|
||||
});
|
||||
|
||||
test('public repository flow keeps railpack available after branch lookup', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('branchFound', true)
|
||||
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']);
|
||||
});
|
||||
|
||||
test('deploy key repository flow shows railpack beta label in build pack selector without beta badge', function () {
|
||||
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
|
||||
->set('current_step', 'repository')
|
||||
->assertSee('Railpack (Beta)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => false,
|
||||
]);
|
||||
|
||||
OauthSetting::create([
|
||||
'provider' => 'google',
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
|
||||
'tenant' => 'example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('logs in an existing user when the oauth provider returns a mixed-case email', function () {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
|
||||
$user = User::factory()->create([
|
||||
'email' => 'username@example.edu',
|
||||
]);
|
||||
|
||||
$provider = \Mockery::mock();
|
||||
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
|
||||
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
|
||||
$provider->shouldReceive('user')->once()->andReturn((object) [
|
||||
'email' => 'UserName@example.edu',
|
||||
'name' => 'Example User',
|
||||
'id' => 'google-user-id',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
|
||||
|
||||
$response = $this->get(route('auth.callback', 'google'));
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect(User::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
InstanceSettings::firstOrCreate([
|
||||
'id' => 0,
|
||||
], [
|
||||
'is_registration_enabled' => false,
|
||||
])->update([
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
|
||||
$provider = \Mockery::mock();
|
||||
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
|
||||
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
|
||||
$provider->shouldReceive('user')->once()->andReturn((object) [
|
||||
'email' => $providerEmail,
|
||||
'name' => 'Example User',
|
||||
'id' => 'google-user-id',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
|
||||
|
||||
$response = $this->from('/login')->get(route('auth.callback', 'google'));
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
expect(User::count())->toBe(0);
|
||||
})->with([
|
||||
'null email' => [null],
|
||||
'blank email' => [' '],
|
||||
]);
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Models\Server;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\Team;
|
||||
use Database\Seeders\ProductionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('creates the root team before seeding the localhost server and predefined shared variables', function () {
|
||||
config([
|
||||
'broadcasting.default' => 'log',
|
||||
'constants.coolify.is_windows_docker_desktop' => true,
|
||||
]);
|
||||
Queue::fake();
|
||||
StartProxy::shouldRun()->andReturn('OK');
|
||||
|
||||
Server::creating(function (Server $server) {
|
||||
if ((int) $server->getKey() === 0) {
|
||||
expect(Team::find(0))->not->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
Server::created(function (Server $server) {
|
||||
SslCertificate::create([
|
||||
'server_id' => $server->id,
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'ssl_certificate' => 'certificate',
|
||||
'ssl_private_key' => 'private-key',
|
||||
'valid_until' => now()->addYear(),
|
||||
'is_ca_certificate' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->seed(ProductionSeeder::class);
|
||||
|
||||
$rootTeam = Team::find(0);
|
||||
$localhostServer = Server::find(0);
|
||||
|
||||
expect($rootTeam)->not->toBeNull()
|
||||
->and($localhostServer)->not->toBeNull()
|
||||
->and($localhostServer->team_id)->toBe(0);
|
||||
|
||||
expect(SharedEnvironmentVariable::query()
|
||||
->where('type', 'server')
|
||||
->where('server_id', 0)
|
||||
->where('team_id', 0)
|
||||
->pluck('key')
|
||||
->all()
|
||||
)->toContain('COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME');
|
||||
|
||||
instanceSettings()->update(['is_registration_enabled' => true]);
|
||||
|
||||
$rootUser = app(CreateNewUser::class)->create([
|
||||
'name' => 'Root User',
|
||||
'email' => 'root@example.com',
|
||||
'password' => 'Password123!',
|
||||
'password_confirmation' => 'Password123!',
|
||||
]);
|
||||
|
||||
expect(Team::whereKey(0)->count())->toBe(1)
|
||||
->and($rootUser->teams()->where('team_id', 0)->exists())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
it('adds profile navigation with an appearance tab and route', function () {
|
||||
$routes = file_get_contents(base_path('routes/web.php'));
|
||||
$profileNavbar = file_get_contents(resource_path('views/components/profile/navbar.blade.php'));
|
||||
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
|
||||
|
||||
expect($routes)
|
||||
->toContain("Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance')")
|
||||
->and($profileNavbar)
|
||||
->toContain('route(\'profile\')')
|
||||
->toContain('route(\'profile.appearance\')')
|
||||
->toContain('General')
|
||||
->toContain('Appearance')
|
||||
->and($profileView)
|
||||
->toContain('<x-profile.navbar />')
|
||||
->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">');
|
||||
});
|
||||
|
||||
it('moves appearance preferences to the profile appearance view', function () {
|
||||
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
|
||||
|
||||
expect($appearanceView)
|
||||
->toContain('<x-profile.navbar />')
|
||||
->toContain("setTheme('light')")
|
||||
->toContain("setTheme('system')")
|
||||
->toContain("setTheme('dark')")
|
||||
->toContain("setWidth('center')")
|
||||
->toContain("setWidth('full')")
|
||||
->toContain("setZoom('100')")
|
||||
->toContain("setZoom('90')")
|
||||
->toContain('aria-label="Use light theme"')
|
||||
->toContain('aria-label="Use system theme"')
|
||||
->toContain('aria-label="Use dark theme"')
|
||||
->toContain('aria-label="Use centered width"')
|
||||
->toContain('aria-label="Use full width"')
|
||||
->toContain('aria-label="Use 100 percent zoom"')
|
||||
->toContain('aria-label="Use 90 percent zoom"')
|
||||
->toContain('max-w-2xl')
|
||||
->toContain('class="space-y-1.5"')
|
||||
->toContain('gap-1.5')
|
||||
->toContain('px-2 py-1 text-sm');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PullChangelog;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Fake releases land in a month that no real release uses, so the generated
|
||||
* changelog file never collides with committed changelogs.
|
||||
*/
|
||||
function fakeReleasesPayload(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'tag_name' => 'v9.9.9',
|
||||
'name' => 'Test Release',
|
||||
'body' => 'Released notes here.',
|
||||
'draft' => false,
|
||||
'published_at' => '1999-01-15T00:00:00Z',
|
||||
],
|
||||
[
|
||||
'tag_name' => 'v9.9.8-draft',
|
||||
'name' => 'Draft Release',
|
||||
'body' => 'Should be skipped.',
|
||||
'draft' => true,
|
||||
'published_at' => '1999-01-10T00:00:00Z',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
File::delete(base_path('changelogs/1999-01.json'));
|
||||
});
|
||||
|
||||
test('releases_url config defaults to the GitHub raw source', function () {
|
||||
expect(config('constants.coolify.releases_url'))
|
||||
->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json');
|
||||
});
|
||||
|
||||
test('PullChangelog fetches from the configured releases_url and writes the changelog', function () {
|
||||
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
|
||||
|
||||
Http::fake([
|
||||
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
|
||||
]);
|
||||
|
||||
(new PullChangelog)->handle();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://example.test/releases.json');
|
||||
|
||||
$path = base_path('changelogs/1999-01.json');
|
||||
expect(File::exists($path))->toBeTrue();
|
||||
|
||||
$data = json_decode(File::get($path), true);
|
||||
expect($data['entries'])->toHaveCount(1)
|
||||
->and($data['entries'][0]['tag_name'])->toBe('v9.9.9');
|
||||
});
|
||||
|
||||
test('PullChangelog skips draft releases', function () {
|
||||
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
|
||||
|
||||
Http::fake([
|
||||
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
|
||||
]);
|
||||
|
||||
(new PullChangelog)->handle();
|
||||
|
||||
$data = json_decode(File::get(base_path('changelogs/1999-01.json')), true);
|
||||
|
||||
$tags = array_column($data['entries'], 'tag_name');
|
||||
expect($tags)->not->toContain('v9.9.8-draft');
|
||||
});
|
||||
@@ -1,17 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('database last_online_at is updated when status unchanged', function () {
|
||||
test('database last_online_at is not updated when status is unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'running:healthy',
|
||||
'last_online_at' => now()->subMinutes(5),
|
||||
]);
|
||||
@@ -40,15 +42,13 @@ test('database last_online_at is updated when status unchanged', function () {
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// last_online_at should be updated even though status didn't change
|
||||
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
|
||||
expect((string) $database->last_online_at)->toBe((string) $oldLastOnline);
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database status is updated when container status changes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
@@ -79,8 +79,7 @@ test('database status is updated when container status changes', function () {
|
||||
|
||||
test('database is not marked exited when containers list is empty', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
@@ -99,3 +98,31 @@ test('database is not marked exited when containers list is empty', function ()
|
||||
// Status should remain running, NOT be set to exited
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
function createPushUpdatePostgresql(Team $team, array $attributes = []): StandalonePostgresql
|
||||
{
|
||||
$lastOnlineAt = $attributes['last_online_at'] ?? null;
|
||||
unset($attributes['last_online_at']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$database = StandalonePostgresql::create(array_merge([
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'postgres-'.str()->random(8),
|
||||
'postgres_password' => 'secret',
|
||||
'status' => 'exited',
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'environment_id' => $environment->id,
|
||||
], $attributes));
|
||||
|
||||
if ($lastOnlineAt !== null) {
|
||||
$database->forceFill(['last_online_at' => $lastOnlineAt])->saveQuietly();
|
||||
}
|
||||
|
||||
return $database;
|
||||
}
|
||||
@@ -16,10 +16,29 @@ beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('dispatches storage check when disk percentage changes', function () {
|
||||
it('dispatches storage check when disk percentage changes above threshold', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Default notification threshold is 80%.
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 85],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 85;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch storage check when disk usage is below threshold', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// 45% is well below the default 80% notification threshold — nothing to do.
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
@@ -28,8 +47,39 @@ it('dispatches storage check when disk percentage changes', function () {
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertNotPushed(ServerStorageCheckJob::class);
|
||||
});
|
||||
|
||||
it('clears stale storage cache when disk usage drops below threshold', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$storageCacheKey = 'storage-check:'.$server->id;
|
||||
|
||||
Cache::put($storageCacheKey, 85, 600);
|
||||
|
||||
$belowThresholdData = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $belowThresholdData);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertNotPushed(ServerStorageCheckJob::class);
|
||||
expect(Cache::missing($storageCacheKey))->toBeTrue();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$aboveThresholdData = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 85],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $aboveThresholdData);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 45;
|
||||
return $job->server->id === $server->id && $job->percentage === 85;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,12 +87,12 @@ it('does not dispatch storage check when disk percentage is unchanged', function
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Simulate a previous push that cached the percentage
|
||||
Cache::put('storage-check:'.$server->id, 45, 600);
|
||||
// Simulate a previous push that cached the percentage (above threshold).
|
||||
Cache::put('storage-check:'.$server->id, 85, 600);
|
||||
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
'filesystem_usage_root' => ['used_percentage' => 85],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
@@ -55,19 +105,19 @@ it('dispatches storage check when disk percentage changes from cached value', fu
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Simulate a previous push that cached 45%
|
||||
Cache::put('storage-check:'.$server->id, 45, 600);
|
||||
// Simulate a previous push that cached 85% (above threshold).
|
||||
Cache::put('storage-check:'.$server->id, 85, 600);
|
||||
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 50],
|
||||
'filesystem_usage_root' => ['used_percentage' => 90],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 50;
|
||||
return $job->server->id === $server->id && $job->percentage === 90;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +190,36 @@ it('dispatches ConnectProxyToNetworksJob again after cache expires', function ()
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
});
|
||||
|
||||
it('respects the configured proxy connect interval', function () {
|
||||
// Interval 0 → the connect-proxy gate key expires immediately, so every
|
||||
// push re-dispatches without a manual Cache::forget. Proves the TTL is
|
||||
// driven by config('constants.proxy.connect_networks_interval_seconds').
|
||||
config(['constants.proxy.connect_networks_interval_seconds' => 0]);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => 'coolify-proxy',
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => ['coolify.managed' => true],
|
||||
],
|
||||
],
|
||||
'filesystem_usage_root' => ['used_percentage' => 10],
|
||||
];
|
||||
|
||||
(new PushServerUpdateJob($server, $data))->handle();
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
|
||||
Queue::fake();
|
||||
(new PushServerUpdateJob($server, $data))->handle();
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
});
|
||||
|
||||
it('uses default queue for PushServerUpdateJob', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
@@ -4,17 +4,21 @@ use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('containers with empty service subId are skipped', function () {
|
||||
$server = Server::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
$serviceApp = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'app-'.str()->random(8),
|
||||
]);
|
||||
|
||||
$data = [
|
||||
@@ -44,12 +48,15 @@ test('containers with empty service subId are skipped', function () {
|
||||
});
|
||||
|
||||
test('containers with valid service subId are processed', function () {
|
||||
$server = Server::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
$serviceApp = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'app-'.str()->random(8),
|
||||
]);
|
||||
|
||||
$data = [
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->first();
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->envA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
});
|
||||
|
||||
test('queryDatabaseByUuidWithinTeam returns database when team owns it', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-team-a',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->envA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
$found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamA->id);
|
||||
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found->uuid)->toBe($database->uuid);
|
||||
expect($found)->toBeInstanceOf(StandalonePostgresql::class);
|
||||
});
|
||||
|
||||
test('queryDatabaseByUuidWithinTeam returns null when team does not own the database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-team-a',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->envA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
$found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamB->id);
|
||||
|
||||
expect($found)->toBeNull();
|
||||
});
|
||||
|
||||
test('queryDatabaseByUuidWithinTeam returns null for unknown uuid', function () {
|
||||
$found = queryDatabaseByUuidWithinTeam('does-not-exist', (string) $this->teamA->id);
|
||||
|
||||
expect($found)->toBeNull();
|
||||
});
|
||||
|
||||
test('queryDatabaseByUuidWithinTeam can query every registered standalone database type without error', function () {
|
||||
foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
|
||||
$count = $modelClass::query()->whereUuid('non-existent-uuid')->count();
|
||||
expect($count)->toBe(0, "{$modelClass} ({$slug}) failed whereUuid() smoke query");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake([ApplicationDeploymentJob::class]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'network' => 'test-network-'.fake()->unique()->word(),
|
||||
]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function makeApplication(int $environmentId, int $destinationId, ?string $gitCommitSha): Application
|
||||
{
|
||||
$attributes = [
|
||||
'environment_id' => $environmentId,
|
||||
'destination_id' => $destinationId,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
];
|
||||
|
||||
if ($gitCommitSha !== null) {
|
||||
$attributes['git_commit_sha'] = $gitCommitSha;
|
||||
}
|
||||
|
||||
return Application::factory()->create($attributes);
|
||||
}
|
||||
|
||||
describe('queue_application_deployment commit resolution', function () {
|
||||
test('uses application git_commit_sha when commit parameter omitted', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-1',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-1')->first();
|
||||
expect($deployment)->not->toBeNull();
|
||||
expect($deployment->commit)->toBe($pinnedSha);
|
||||
});
|
||||
|
||||
test('falls back to HEAD when both commit parameter and git_commit_sha are unset', function () {
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-2',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-2')->first();
|
||||
expect($deployment->commit)->toBe('HEAD');
|
||||
});
|
||||
|
||||
test('explicit commit parameter overrides application git_commit_sha', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$webhookSha = '111222333444555666777888999000aaabbbccc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-3',
|
||||
commit: $webhookSha,
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-3')->first();
|
||||
expect($deployment->commit)->toBe($webhookSha);
|
||||
});
|
||||
|
||||
test('treats empty string commit parameter as unset and uses git_commit_sha', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-4',
|
||||
commit: '',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-4')->first();
|
||||
expect($deployment->commit)->toBe($pinnedSha);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
|
||||
describe('deployment_queue helper', function () {
|
||||
test('uses the high queue on self-hosted', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
expect(deployment_queue())->toBe('high');
|
||||
});
|
||||
|
||||
test('uses the deployments queue on cloud', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
expect(deployment_queue())->toBe('deployments');
|
||||
});
|
||||
});
|
||||
|
||||
describe('crons_queue helper', function () {
|
||||
test('uses the high queue on self-hosted', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
expect(crons_queue())->toBe('high');
|
||||
});
|
||||
|
||||
test('uses the crons queue on cloud', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
expect(crons_queue())->toBe('crons');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start action job routing', function () {
|
||||
test('routes to the deployments queue on cloud', function (string $actionClass) {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
expect($actionClass::makeJob()->queue)->toBe('deployments');
|
||||
})->with([
|
||||
StartDatabase::class,
|
||||
StartDatabaseProxy::class,
|
||||
StartService::class,
|
||||
]);
|
||||
|
||||
test('routes to the high queue on self-hosted', function (string $actionClass) {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
expect($actionClass::makeJob()->queue)->toBe('high');
|
||||
})->with([
|
||||
StartDatabase::class,
|
||||
StartDatabaseProxy::class,
|
||||
StartService::class,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('scheduled job routing', function () {
|
||||
test('scheduled jobs use the crons queue on cloud', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
expect((new ScheduledJobManager)->queue)->toBe('crons');
|
||||
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('crons');
|
||||
});
|
||||
|
||||
test('scheduled jobs use the high queue on self-hosted', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
expect((new ScheduledJobManager)->queue)->toBe('high');
|
||||
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('high');
|
||||
});
|
||||
});
|
||||
@@ -32,3 +32,88 @@ it('keeps realtime terminal server logging restricted to development environment
|
||||
->toContain('if (!terminalDebugEnabled) {')
|
||||
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
|
||||
});
|
||||
|
||||
it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('ws.isAlive = true;')
|
||||
->toContain("ws.on('pong'")
|
||||
->toContain('ws.ping();')
|
||||
->toContain('ws.terminate();')
|
||||
->toContain('HEARTBEAT_INTERVAL_MS');
|
||||
});
|
||||
|
||||
it('removes the keepalive short-circuit that fired when the tab was hidden', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects');
|
||||
});
|
||||
|
||||
it('uses a fast probe timeout when the tab regains visibility', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("'Visibility-resume timeout'");
|
||||
});
|
||||
|
||||
it('does not hard close terminal sessions after 30 minutes on the server', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
||||
->not->toContain("ws.send('idle-timeout');")
|
||||
->not->toContain("ws.close(1000, 'Idle timeout');");
|
||||
});
|
||||
|
||||
it('does not close the client terminal from an idle-timeout sentinel', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->not->toContain("event.data === 'idle-timeout'")
|
||||
->not->toContain('Terminal closed after 30 minutes of inactivity.');
|
||||
});
|
||||
|
||||
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
|
||||
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
|
||||
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toContain('public bool $isTerminalConnected = false;')
|
||||
->toContain("#[On('terminalConnected')]")
|
||||
->toContain('public function markTerminalConnected(): void')
|
||||
->toContain('public function keepTerminalPageAlive(): void')
|
||||
->and($terminalView)
|
||||
->toContain('@if ($isTerminalConnected)')
|
||||
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
|
||||
});
|
||||
|
||||
it('replays the last command on reconnect so the PTY respawns automatically', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('lastSentCommand')
|
||||
->toContain('Replaying last command after reconnect.')
|
||||
->toContain('this.lastSentCommand = null;');
|
||||
});
|
||||
|
||||
it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('authReady: false')
|
||||
->toContain('pendingMessages: []')
|
||||
->toContain('userSession.pendingMessages.push(message)')
|
||||
->toContain('userSession.authReady = true');
|
||||
});
|
||||
|
||||
it('preserves terminal scrollback across transient reconnects', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('── Connection lost at')
|
||||
->toContain('── Reconnected at')
|
||||
// resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
|
||||
->not->toContain("this.term.reset();\n this.term.clear();");
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$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',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders responsive checkbox classes on the application configuration page', function () {
|
||||
$response = $this->get(route('project.application.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Use a Build Server?');
|
||||
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
|
||||
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
|
||||
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('pt-2 w-full sm:w-96', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('min-w-fit');
|
||||
});
|
||||
|
||||
it('renders responsive checkbox classes on the server page', function () {
|
||||
$response = $this->get(route('server.show', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Use it as a build server?');
|
||||
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
|
||||
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
|
||||
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('w-full sm:w-96', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('min-w-fit');
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]));
|
||||
});
|
||||
|
||||
it('schedules RegenerateSslCertJob with onOneServer to prevent multi-server double dispatch', function () {
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())->first(
|
||||
fn ($e) => str_contains((string) $e->description, 'RegenerateSslCertJob')
|
||||
);
|
||||
|
||||
expect($event)->not->toBeNull();
|
||||
expect($event->onOneServer)->toBeTrue();
|
||||
});
|
||||
|
||||
it('schedules every production job with onOneServer', function () {
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$jobEvents = collect($schedule->events())->filter(
|
||||
fn ($e) => str_contains((string) $e->description, 'App\\Jobs\\')
|
||||
);
|
||||
|
||||
expect($jobEvents)->not->toBeEmpty();
|
||||
|
||||
$jobEvents->each(function ($event) {
|
||||
expect($event->onOneServer)->toBeTrue(
|
||||
"Scheduled job [{$event->description}] is missing ->onOneServer()"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('dispatches scheduled tasks across chunks', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
|
||||
Queue::fake();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
'docker_cleanup_frequency' => '0 * * * *',
|
||||
]);
|
||||
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
ScheduledTask::factory()
|
||||
->count(101)
|
||||
->create([
|
||||
'team_id' => $team->id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '* * * * *',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
(new ScheduledJobManager)->handle();
|
||||
|
||||
Queue::assertPushed(ScheduledTaskJob::class, 101);
|
||||
});
|
||||
|
||||
it('skips expensive dispatch for non-due schedules while seeding dedup cache', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
|
||||
Queue::fake();
|
||||
|
||||
$application = createScheduledTaskApplication();
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $application->environment->project->team_id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '0 2 * * *',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
(new ScheduledJobManager)->handle();
|
||||
|
||||
Queue::assertNotPushed(ScheduledTaskJob::class);
|
||||
expect(Cache::get("scheduled-task:{$task->id}"))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not query relationships when constructing scheduled task jobs', function () {
|
||||
$application = createScheduledTaskApplication();
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $application->environment->project->team_id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '* * * * *',
|
||||
'enabled' => true,
|
||||
])->fresh();
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$job = new ScheduledTaskJob($task);
|
||||
|
||||
expect(DB::getQueryLog())->toBeEmpty()
|
||||
->and($job->queue)->toBe(crons_queue())
|
||||
->and($job->timeout)->toBe(300);
|
||||
});
|
||||
|
||||
function createScheduledTaskApplication(): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
'docker_cleanup_frequency' => '0 * * * *',
|
||||
]);
|
||||
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'status' => 'running',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Console\Commands\ViewScheduledLogs;
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('logs:scheduled --date option', function () {
|
||||
test('rejects a malformed date and exits before touching the shell', function () {
|
||||
$this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn'])
|
||||
->expectsOutputToContain('Invalid date format')
|
||||
->assertExitCode(ViewScheduledLogs::INVALID);
|
||||
|
||||
expect(file_exists('/tmp/pwn'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('accepts a well-formed date', function () {
|
||||
$this->artisan('logs:scheduled', ['--date' => '2025-01-01'])
|
||||
->assertExitCode(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$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 = $this->project->environments()->first();
|
||||
});
|
||||
|
||||
it('returns null when neither application nor service is set', function () {
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($task->server())->toBeNull();
|
||||
});
|
||||
|
||||
it('does not throw when accessing dynamic properties on a parentless task', function () {
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect(fn () => $task->server())->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
it('resolves server via application destination', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'application_id' => $application->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($task->server()?->id)->toBe($this->server->id);
|
||||
});
|
||||
|
||||
it('resolves server via service destination', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($task->server()?->id)->toBe($this->server->id);
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeAuditTeamUser(): array
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $team]);
|
||||
test()->actingAs($user);
|
||||
|
||||
return [$team, $user];
|
||||
}
|
||||
|
||||
function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string
|
||||
{
|
||||
$token = $user->createToken('audit-test', $abilities);
|
||||
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
return Application::create([
|
||||
'name' => 'audit-test-app',
|
||||
'git_repository' => "https://github.com/{$repo}",
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
describe('audit channel helper', function () {
|
||||
test('auditLog writes structured payload to audit channel', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('test.event', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'test.event'
|
||||
&& $context['custom_field'] === 'value'
|
||||
&& array_key_exists('ip', $context)
|
||||
&& array_key_exists('user_id', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.event', ['custom_field' => 'value']);
|
||||
});
|
||||
|
||||
test('auditLog warning level routes correctly', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.failed', [], 'warning');
|
||||
});
|
||||
|
||||
test('auditLogWebhookFailure logs warning with provider tag', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->once()
|
||||
->with('webhook.github.signature_failed', Mockery::on(function ($context) {
|
||||
return $context['reason'] === 'invalid_signature'
|
||||
&& $context['event'] === 'webhook.github.signature_failed'
|
||||
&& array_key_exists('ip', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']);
|
||||
});
|
||||
|
||||
test('auditLog never includes raw secret keys in context', function () {
|
||||
$captured = null;
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) {
|
||||
$captured = $context;
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.private_key.created', [
|
||||
'team_id' => '1',
|
||||
'private_key_uuid' => 'abc',
|
||||
'fingerprint' => 'SHA256:xyz',
|
||||
]);
|
||||
|
||||
expect($captured)->toBeArray();
|
||||
// Helper itself never injects secret-bearing keys.
|
||||
$disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret'];
|
||||
foreach (array_keys($captured) as $key) {
|
||||
expect(in_array(strtolower($key), $disallowed, true))->toBeFalse();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook signature failure logging', function () {
|
||||
test('GitHub manual webhook with bad signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.github.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('GitLab manual webhook with bad token logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.gitlab.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'wrong-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('Bitbucket manual webhook with malformed signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.bitbucket.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha1=anyvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('Gitea manual webhook with bad signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.gitea.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API mutation audit logging', function () {
|
||||
test('private key creation emits api.private_key.created audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.private_key.created', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'api.private_key.created'
|
||||
&& ! array_key_exists('private_key', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
// Generate a valid OpenSSH-format private key for the test.
|
||||
$opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
|
||||
base64_encode(str_repeat('a', 256)).
|
||||
"\n-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/security/keys', [
|
||||
'name' => 'test-key',
|
||||
'description' => 'audit test',
|
||||
'private_key' => $opensshKey,
|
||||
]);
|
||||
|
||||
// Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201.
|
||||
expect($response->status())->toBeIn([201, 422]);
|
||||
});
|
||||
|
||||
test('enable_api denial for non-root team emits warning audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.instance.enable_denied', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('project creation emits api.project.created audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.project.created', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'api.project.created'
|
||||
&& ! empty($context['project_uuid'])
|
||||
&& $context['project_name'] === 'audit-project';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'audit-project',
|
||||
'description' => 'audit',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threat-detection audit logging (Phase 2)', function () {
|
||||
test('missing bearer token logs api.auth.unauthenticated', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.unauthenticated', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('expired bearer token logs api.auth.unauthenticated', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = $user->createToken('expired-audit', ['read'], now()->subDay());
|
||||
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.unauthenticated', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('read-only token hitting write endpoint logs api.auth.ability_denied', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$readToken = makeAuditApiToken($user, $team, ['read']);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.ability_denied', Mockery::on(function ($ctx) {
|
||||
return in_array('write', $ctx['required_abilities'] ?? [], true);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'should-fail',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('sentinel push without Authorization logs token_missing', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
|
||||
return $ctx['reason'] === 'token_missing';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->postJson('/api/v1/sentinel/push', []);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('sentinel push with un-decryptable bearer logs decrypt_failed', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
|
||||
return $ctx['reason'] === 'decrypt_failed';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer not-a-valid-encrypted-payload',
|
||||
])->postJson('/api/v1/sentinel/push', []);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->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,
|
||||
]);
|
||||
});
|
||||
|
||||
function cacheGithubAppSetupState(string $state, string $action, GithubApp $githubApp): void
|
||||
{
|
||||
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
|
||||
'action' => $action,
|
||||
'github_app_id' => $githubApp->id,
|
||||
'team_id' => $githubApp->team_id,
|
||||
], now()->addMinutes(15));
|
||||
}
|
||||
|
||||
function authenticateGithubSetupCallbackTest(object $test): void
|
||||
{
|
||||
$test->actingAs($test->user);
|
||||
session(['currentTeam' => $test->team]);
|
||||
}
|
||||
|
||||
function fakeGithubManifestConversion(): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/app-manifests/*/conversions' => Http::response([
|
||||
'id' => 987654,
|
||||
'slug' => 'attacker-controlled-app',
|
||||
'client_id' => 'new-client-id',
|
||||
'client_secret' => 'new-client-secret',
|
||||
'pem' => $privateKey,
|
||||
'webhook_secret' => 'new-webhook-secret',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
function configureGithubAppCredentials(GithubApp $githubApp): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
$privateKeyModel = PrivateKey::create([
|
||||
'name' => 'github-app-test-key',
|
||||
'private_key' => $privateKey,
|
||||
'team_id' => $githubApp->team_id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
|
||||
$githubApp->forceFill([
|
||||
'app_id' => 123456,
|
||||
'private_key_id' => $privateKeyModel->id,
|
||||
])->save();
|
||||
}
|
||||
|
||||
function fakeGithubInstallationVerification(int $appId): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/*' => Http::response([
|
||||
'id' => 555,
|
||||
'app_id' => $appId,
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
function fakeGithubInstallationVerificationFailure(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/*' => Http::response(['message' => 'Not Found'], 404),
|
||||
]);
|
||||
}
|
||||
|
||||
it('requires authentication before processing github app manifest callbacks', function () {
|
||||
fakeGithubManifestConversion();
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
|
||||
->assertRedirect();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->app_id)->toBeNull()
|
||||
->and($this->githubApp->client_id)->toBeNull()
|
||||
->and($this->githubApp->webhook_secret)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects github app manifest callbacks with invalid state without calling github', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state='.$this->githubApp->uuid.'&code=attacker-code')
|
||||
->assertNotFound();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->app_id)->toBeNull()
|
||||
->and($this->githubApp->client_id)->toBeNull()
|
||||
->and($this->githubApp->webhook_secret)->toBeNull();
|
||||
});
|
||||
|
||||
it('blocks rebinding an already configured github app through manifest callback', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
|
||||
$this->githubApp->forceFill([
|
||||
'app_id' => 123456,
|
||||
'client_id' => 'existing-client-id',
|
||||
'client_secret' => 'existing-client-secret',
|
||||
'webhook_secret' => 'existing-webhook-secret',
|
||||
])->save();
|
||||
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
|
||||
->assertForbidden();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->app_id)->toBe(123456)
|
||||
->and($this->githubApp->client_id)->toBe('existing-client-id')
|
||||
->and($this->githubApp->webhook_secret)->toBe('existing-webhook-secret');
|
||||
});
|
||||
|
||||
it('configures an unbound github app with a valid one-time manifest state', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
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::assertSentCount(1);
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->name)->toBe('attacker-controlled-app')
|
||||
->and($this->githubApp->app_id)->toBe(987654)
|
||||
->and($this->githubApp->client_id)->toBe('new-client-id')
|
||||
->and($this->githubApp->webhook_secret)->toBe('new-webhook-secret')
|
||||
->and($this->githubApp->private_key_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects replayed github app manifest states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
|
||||
->assertRedirect();
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
|
||||
->assertNotFound();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('requires authentication before processing github app install callbacks', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
|
||||
->assertRedirect();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->installation_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects github app install callbacks for an unknown github app', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456')
|
||||
->assertNotFound();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('rejects an installation id that github does not confirm belongs to the app', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
fakeGithubInstallationVerificationFailure();
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999')
|
||||
->assertForbidden();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->installation_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('sets installation id when github confirms it belongs to the app', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
fakeGithubInstallationVerification($this->githubApp->app_id);
|
||||
|
||||
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->installation_id)->toBe(123456);
|
||||
});
|
||||
|
||||
it('allows reinstalling an already configured github app installation id', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
$this->githubApp->forceFill(['installation_id' => 111111])->save();
|
||||
fakeGithubInstallationVerification($this->githubApp->app_id);
|
||||
|
||||
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->installation_id)->toBe(222222);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\SentinelController;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.store' => 'array']);
|
||||
|
||||
Queue::fake();
|
||||
Cache::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$this->token = $this->server->settings->sentinel_token;
|
||||
});
|
||||
|
||||
function pushSentinel(string $token, array $payload)
|
||||
{
|
||||
return test()->postJson('/api/v1/sentinel/push', $payload, [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
]);
|
||||
}
|
||||
|
||||
function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): array
|
||||
{
|
||||
return [
|
||||
'containers' => $containers,
|
||||
'filesystem_usage_root' => ['used_percentage' => $diskPercentage],
|
||||
];
|
||||
}
|
||||
|
||||
$running = fn () => [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
|
||||
|
||||
it('skips dispatch decision when sentinel lock acquisition times out', function () use ($running) {
|
||||
$lock = Mockery::mock();
|
||||
$lock->shouldReceive('block')
|
||||
->once()
|
||||
->with(5, Mockery::type('callable'))
|
||||
->andThrow(LockTimeoutException::class);
|
||||
|
||||
Cache::shouldReceive('lock')
|
||||
->once()
|
||||
->with('sentinel:push-lock:'.$this->server->id, 10)
|
||||
->andReturn($lock);
|
||||
|
||||
$controller = new SentinelController;
|
||||
$method = new ReflectionMethod($controller, 'shouldDispatchUpdate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($controller, $this->server, sentinelPayload($running())))->toBeFalse();
|
||||
});
|
||||
|
||||
it('dispatches the job on the first push', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('skips the job when the second push is identical', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('updates the heartbeat even when the job is skipped', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('accepts an empty container list as a heartbeat when no containers are running', function () {
|
||||
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
||||
|
||||
pushSentinel($this->token, sentinelPayload([]))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('rejects malformed sentinel payloads before touching server state', function (array $payload) {
|
||||
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
||||
$originalHeartbeat = $this->server->fresh()->sentinel_updated_at;
|
||||
|
||||
pushSentinel($this->token, $payload)
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Validation failed.')
|
||||
->assertJsonValidationErrors('containers');
|
||||
|
||||
Queue::assertNotPushed(PushServerUpdateJob::class);
|
||||
expect($this->server->fresh()->sentinel_updated_at)->toBe($originalHeartbeat);
|
||||
expect(Cache::has('sentinel:push-hash:'.$this->server->id))->toBeFalse();
|
||||
expect(Cache::has('sentinel:push-force:'.$this->server->id))->toBeFalse();
|
||||
})->with([
|
||||
'missing containers' => [[]],
|
||||
'non-array containers' => [['containers' => 'not-an-array']],
|
||||
]);
|
||||
|
||||
it('guards the dedupe decision with a server scoped atomic cache lock', function () {
|
||||
$controller = file_get_contents(app_path('Http/Controllers/Api/SentinelController.php'));
|
||||
|
||||
expect($controller)
|
||||
->toContain('$lockKey = "sentinel:push-lock:{$server->id}";')
|
||||
->toContain('Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool')
|
||||
->toContain('Cache::put($hashKey, $hash, now()->addDay())')
|
||||
->toContain("Cache::put(\$forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300))");
|
||||
});
|
||||
|
||||
it('dispatches the job when container state changes', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
$exited = [['name' => 'app-1', 'state' => 'exited', 'health_status' => 'unhealthy']];
|
||||
pushSentinel($this->token, sentinelPayload($exited))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 2);
|
||||
});
|
||||
|
||||
it('ignores health status changes while container lifecycle state is unchanged', function () {
|
||||
$healthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
|
||||
$unhealthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'unhealthy']];
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($healthy))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($unhealthy))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('ignores disk percentage changes (excluded from the hash)', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 42.0))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 88.0))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('ignores container reordering (hash is sorted by name)', function () {
|
||||
$order1 = [
|
||||
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
|
||||
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
|
||||
];
|
||||
$order2 = [
|
||||
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
|
||||
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
|
||||
];
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($order1))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($order2))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('force-dispatches an identical push after the force window expires', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
// Simulate the force key TTL elapsing.
|
||||
Cache::forget('sentinel:push-force:'.$this->server->id);
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 2);
|
||||
});
|
||||
|
||||
it('rejects an invalid token without dispatching', function () use ($running) {
|
||||
pushSentinel('not-a-real-token', sentinelPayload($running()))->assertUnauthorized();
|
||||
|
||||
Queue::assertNotPushed(PushServerUpdateJob::class);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -78,11 +79,73 @@ describe('ServerSetting::isValidSentinelToken', function () {
|
||||
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for null sentinel token', function () {
|
||||
expect(ServerSetting::isValidSentinelToken(null))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects the reported PoC payload', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerSetting::ensureValidSentinelToken', function () {
|
||||
it('regenerates empty sentinel token via ensureValidSentinelToken', function () {
|
||||
$settings = $this->server->settings;
|
||||
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
|
||||
|
||||
$settings->refresh();
|
||||
$token = $settings->ensureValidSentinelToken();
|
||||
|
||||
expect($token)->not->toBeEmpty();
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
expect($settings->fresh()->sentinel_token)->toBe($token);
|
||||
});
|
||||
|
||||
it('regenerates token when stored value cannot be decrypted', function () {
|
||||
$settings = $this->server->settings;
|
||||
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']);
|
||||
|
||||
$settings->refresh();
|
||||
$token = $settings->ensureValidSentinelToken();
|
||||
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
expect($settings->fresh()->sentinel_token)->toBe($token);
|
||||
});
|
||||
|
||||
it('returns existing valid token without regenerating', function () {
|
||||
$settings = $this->server->settings;
|
||||
$original = $settings->sentinel_token;
|
||||
|
||||
$token = $settings->ensureValidSentinelToken();
|
||||
|
||||
expect($token)->toBe($original);
|
||||
});
|
||||
|
||||
it('throws RuntimeException only when regeneration also fails', function () {
|
||||
$settings = $this->server->settings;
|
||||
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
|
||||
|
||||
$stub = new class extends ServerSetting
|
||||
{
|
||||
protected $table = 'server_settings';
|
||||
|
||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
|
||||
{
|
||||
DB::table('server_settings')->where('id', $this->id)->update([
|
||||
'sentinel_token' => encrypt('invalid token with spaces!'),
|
||||
]);
|
||||
|
||||
return '';
|
||||
}
|
||||
};
|
||||
$stub->setRawAttributes($settings->fresh()->getAttributes(), true);
|
||||
$stub->exists = true;
|
||||
|
||||
expect(fn () => $stub->ensureValidSentinelToken())
|
||||
->toThrow(RuntimeException::class, 'Sentinel token invalid after regeneration');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated sentinel tokens are valid', function () {
|
||||
it('generates tokens that pass format validation', function () {
|
||||
$settings = $this->server->settings;
|
||||
@@ -92,4 +155,11 @@ describe('generated sentinel tokens are valid', function () {
|
||||
expect($token)->not->toBeEmpty();
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns the same value the cast reads back', function () {
|
||||
$settings = $this->server->settings;
|
||||
$returned = $settings->generateSentinelToken(save: true, ignoreEvent: true);
|
||||
|
||||
expect($settings->fresh()->sentinel_token)->toBe($returned);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['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->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$newToken = $this->user->createToken('write-token', ['write']);
|
||||
$newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
|
||||
$this->token = $newToken->plainTextToken;
|
||||
});
|
||||
|
||||
it('PATCH updates connection_timeout via API', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 45,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect($this->server->settings->fresh()->connection_timeout)->toBe(45);
|
||||
});
|
||||
|
||||
it('PATCH rejects connection_timeout out of range', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
|
||||
it('PATCH rejects connection_timeout above max', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 999,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
|
||||
it('PATCH rejects non-integer connection_timeout', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 'fast',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults connection_timeout to 10 seconds for new servers', function () {
|
||||
expect($this->server->settings->connection_timeout)->toBe(10);
|
||||
});
|
||||
|
||||
it('persists a custom connection_timeout value', function () {
|
||||
$this->server->settings->connection_timeout = 30;
|
||||
$this->server->settings->save();
|
||||
|
||||
expect($this->server->settings->fresh()->connection_timeout)->toBe(30);
|
||||
});
|
||||
|
||||
it('returns the per-server connection_timeout from getConnectionTimeout', function () {
|
||||
$this->server->settings->connection_timeout = 45;
|
||||
$this->server->settings->save();
|
||||
|
||||
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45);
|
||||
});
|
||||
|
||||
it('falls back to config default when connection_timeout is invalid', function () {
|
||||
$this->server->settings->connection_timeout = 0;
|
||||
$this->server->settings->saveQuietly();
|
||||
|
||||
$expected = (int) config('constants.ssh.connection_timeout');
|
||||
|
||||
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Destination\New\Docker;
|
||||
use App\Livewire\Server\Destinations;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('destination creation modal can mount with selected team server even when global usable server list excludes it', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'is_build_server' => true,
|
||||
]);
|
||||
|
||||
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
|
||||
|
||||
Livewire::test(Docker::class, ['server_id' => (string) $server->id])
|
||||
->assertSet('selectedServer.id', $server->id)
|
||||
->assertSet('serverId', (string) $server->id);
|
||||
});
|
||||
|
||||
test('server destinations page renders when selected server has no destinations', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'is_build_server' => true,
|
||||
]);
|
||||
|
||||
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
|
||||
|
||||
$this->get(route('server.destinations', ['server_uuid' => $server->uuid]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Destinations')
|
||||
->assertSee('No destinations configured for this server yet.')
|
||||
->assertDontSee('Server not found.');
|
||||
});
|
||||
|
||||
test('global destinations page does not render per-server empty states beside existing destinations', function () {
|
||||
$serverWithDestination = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$serverWithDestination->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$serverWithoutDestination = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$serverWithoutDestination->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
StandaloneDocker::withoutEvents(fn () => $serverWithoutDestination->standaloneDockers()->delete());
|
||||
|
||||
$this->get(route('destination.index'))
|
||||
->assertSuccessful()
|
||||
->assertSee($serverWithDestination->standaloneDockers()->first()->name)
|
||||
->assertDontSee('No destinations found.');
|
||||
});
|
||||
|
||||
test('global destinations page renders a single empty state when no usable servers have destinations', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
|
||||
|
||||
$this->get(route('destination.index'))
|
||||
->assertSuccessful()
|
||||
->assertSee('No destinations found.');
|
||||
});
|
||||
|
||||
test('adding a discovered swarm destination stores the selected network name', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'is_swarm_manager' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(Destinations::class, ['server_uuid' => $server->uuid])
|
||||
->call('add', 'customer-network');
|
||||
|
||||
expect(SwarmDocker::where('server_id', $server->id)->where('network', 'customer-network')->exists())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Channels\EmailChannel;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->emailNotificationSettings()->update([
|
||||
'use_instance_email_settings' => true,
|
||||
'server_unreachable_email_notifications' => true,
|
||||
'server_reachable_email_notifications' => true,
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
it('sends Unreachable notification when threshold reached and not yet notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 2,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertSentTo($this->team, Unreachable::class);
|
||||
expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not send Unreachable on first transient failure (count=1)', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 1,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('does not send Unreachable when already notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 5,
|
||||
'unreachable_notification_sent' => true,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('sends Reachable notification on recovery when previously notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => true]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => true,
|
||||
])->save();
|
||||
|
||||
$fresh = $this->server->fresh();
|
||||
expect($fresh->unreachable_notification_sent)->toBeTrue();
|
||||
expect((bool) $fresh->settings->is_reachable)->toBeTrue();
|
||||
|
||||
ServerReachabilityChanged::dispatch($fresh);
|
||||
|
||||
Notification::assertSentTo($this->team, Reachable::class);
|
||||
expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not send Reachable when never notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => true]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('routes Unreachable notification through EmailChannel when email toggle is on', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 2,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) {
|
||||
return in_array(EmailChannel::class, $channels);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Import as DatabaseImport;
|
||||
use App\Livewire\Project\Service\Heading;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('app.maintenance.store', 'array');
|
||||
Config::set('queue.default', 'sync');
|
||||
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
Once::flush();
|
||||
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverA->id,
|
||||
'network' => 'team-a-network',
|
||||
]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverB->id,
|
||||
'network' => 'team-b-network',
|
||||
]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
$this->otherService = Service::factory()->create([
|
||||
'server_id' => $this->serverB->id,
|
||||
'destination_id' => $this->destinationB->id,
|
||||
'destination_type' => $this->destinationB->getMorphClass(),
|
||||
'environment_id' => $this->environmentB->id,
|
||||
]);
|
||||
$this->otherServiceApplication = ServiceApplication::create([
|
||||
'service_id' => $this->otherService->id,
|
||||
'name' => 'other-app',
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
$this->otherServiceDatabase = ServiceDatabase::create([
|
||||
'service_id' => $this->otherService->id,
|
||||
'name' => 'other-db',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'custom_type' => 'postgresql',
|
||||
]);
|
||||
|
||||
$this->ownService = Service::factory()->create([
|
||||
'server_id' => $this->serverA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
'environment_id' => $this->environmentA->id,
|
||||
]);
|
||||
$this->ownServiceDatabase = ServiceDatabase::create([
|
||||
'service_id' => $this->ownService->id,
|
||||
'name' => 'own-db',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'custom_type' => 'postgresql',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('does not open service application detail route from another team', function () {
|
||||
$this->withoutExceptionHandling();
|
||||
|
||||
$this->get(route('project.service.index', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $this->otherService->uuid,
|
||||
'stack_service_uuid' => $this->otherServiceApplication->uuid,
|
||||
]));
|
||||
})->throws(NotFoundHttpException::class);
|
||||
|
||||
test('does not open service database backups route from another team', function () {
|
||||
$this->withoutExceptionHandling();
|
||||
|
||||
$this->get(route('project.service.database.backups', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $this->otherService->uuid,
|
||||
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
|
||||
]));
|
||||
})->throws(NotFoundHttpException::class);
|
||||
|
||||
test('does not resolve service database import component from another team', function () {
|
||||
$component = app(DatabaseImport::class);
|
||||
$component->parameters = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $this->otherService->uuid,
|
||||
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
|
||||
];
|
||||
|
||||
$component->getContainers();
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('service heading does not hydrate with another team service', function () {
|
||||
Livewire::test(Heading::class, ['service' => $this->otherService]);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('owner can still hydrate service heading with own service', function () {
|
||||
Livewire::test(Heading::class, [
|
||||
'service' => $this->ownService,
|
||||
'parameters' => [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $this->ownService->uuid,
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\SettingsDropdown;
|
||||
use App\Models\User;
|
||||
use App\Services\ChangelogService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders the changelog modal above the desktop sidebar toggle', function () {
|
||||
$user = new User(['email' => 'test@example.com']);
|
||||
$user->id = 1;
|
||||
|
||||
Auth::setUser($user);
|
||||
|
||||
app()->instance(ChangelogService::class, new class extends ChangelogService
|
||||
{
|
||||
public function getEntriesForUser(User $user): Collection
|
||||
{
|
||||
return collect([
|
||||
(object) [
|
||||
'tag_name' => 'v1.0.0',
|
||||
'title' => 'Test Release',
|
||||
'content' => 'Release notes',
|
||||
'content_html' => '<p>Release notes</p>',
|
||||
'published_at' => Carbon::parse('2026-05-01'),
|
||||
'is_read' => false,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getUnreadCountForUser(User $user): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar'])
|
||||
->call('openWhatsNewModal')
|
||||
->assertSee('Changelog')
|
||||
->assertSee('z-[60]', false)
|
||||
->assertSee('closeWhatsNewModal', false);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\SettingsDropdown;
|
||||
|
||||
it('keeps changelog and the theme switcher in the sidebar without the old preferences trigger', function () {
|
||||
$navbarView = file_get_contents(resource_path('views/components/navbar.blade.php'));
|
||||
|
||||
expect($navbarView)
|
||||
->toContain('<livewire:settings-dropdown trigger="changelog-sidebar" />')
|
||||
->not->toContain('<livewire:settings-dropdown />')
|
||||
->toContain('aria-label="Theme switcher"')
|
||||
->toContain('aria-label="Use light theme"')
|
||||
->toContain('aria-label="Use system theme"')
|
||||
->toContain('aria-label="Use dark theme"')
|
||||
->toContain('cycleTheme()')
|
||||
->toContain("const themes = ['light', 'system', 'dark'];")
|
||||
->toContain('pl-2 pr-3 items-start gap-3')
|
||||
->toContain('class="flex min-w-0 flex-1 flex-col"')
|
||||
->toContain('class="min-w-0 flex-1"')
|
||||
->toContain('class="flex h-8 w-full items-center justify-between');
|
||||
});
|
||||
|
||||
it('keeps changelog and appearance options out of the preferences dropdown', function () {
|
||||
$dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php'));
|
||||
|
||||
expect($dropdownView)
|
||||
->toContain("\$trigger === 'changelog-sidebar'")
|
||||
->toContain('title="What\'s New"')
|
||||
->toContain('aria-label="What\'s New"')
|
||||
->toContain('wire:click="openWhatsNewModal"')
|
||||
->toContain('class="relative text-left menu-item"')
|
||||
->toContain('class="text-left menu-item-label"')
|
||||
->toContain("What's New</span>")
|
||||
->toContain('M9.813 15.904 9 18.75')
|
||||
->not->toContain('<span>Changelog</span>')
|
||||
->not->toContain('Appearance</div>')
|
||||
->not->toContain("@click=\"setTheme('dark'); dropdownOpen = false\"")
|
||||
->not->toContain("@click=\"setTheme('light'); dropdownOpen = false\"")
|
||||
->not->toContain("@click=\"setTheme('system'); dropdownOpen = false\"");
|
||||
});
|
||||
|
||||
it('opens and closes the changelog modal state', function () {
|
||||
$component = new SettingsDropdown;
|
||||
$component->trigger = 'changelog-sidebar';
|
||||
|
||||
expect($component->trigger)->toBe('changelog-sidebar')
|
||||
->and($component->showWhatsNewModal)->toBeFalse();
|
||||
|
||||
$component->openWhatsNewModal();
|
||||
|
||||
expect($component->showWhatsNewModal)->toBeTrue();
|
||||
|
||||
$component->closeWhatsNewModal();
|
||||
|
||||
expect($component->showWhatsNewModal)->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses the default button palette for the changelog fetch action in light mode', function () {
|
||||
$dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php'));
|
||||
|
||||
expect($dropdownView)
|
||||
->toContain('wire:click="manualFetchChangelog"')
|
||||
->not->toContain('bg-coolgray-200 hover:bg-coolgray-300');
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* SSH multiplexing now relies on OpenSSH's native lazy ControlMaster handling.
|
||||
* Coolify should add mux options to real ssh/scp commands, but must not pre-warm
|
||||
* background masters with separate `ssh -fN` processes.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeMuxServer(): Server
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
|
||||
$privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n".
|
||||
"QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n".
|
||||
"hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n".
|
||||
"AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n".
|
||||
"uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n".
|
||||
'-----END OPENSSH PRIVATE KEY-----';
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'mux-test-key-'.uniqid(),
|
||||
'private_key' => $privateKeyContent,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent);
|
||||
|
||||
return Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
}
|
||||
|
||||
it('does not prewarm a background ssh master', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake();
|
||||
|
||||
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('adds native openssh multiplexing options to ssh commands', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
Process::fake();
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok');
|
||||
|
||||
expect($command)
|
||||
->toContain('-o ControlMaster=auto')
|
||||
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
|
||||
->toContain('-o ControlPersist=3600')
|
||||
->not->toContain('-O check')
|
||||
->not->toContain('ssh -fN');
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('can generate terminal ssh commands without a hard command timeout', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
|
||||
|
||||
expect($command)
|
||||
->toStartWith('ssh ')
|
||||
->not->toStartWith('timeout ')
|
||||
->not->toContain('timeout 3600 ssh');
|
||||
});
|
||||
|
||||
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true);
|
||||
|
||||
expect($command)
|
||||
->not->toContain('-o ControlMaster=auto')
|
||||
->not->toContain('-o ControlPath=')
|
||||
->not->toContain('-o ControlPersist=');
|
||||
});
|
||||
|
||||
it('adds native openssh multiplexing options to scp commands', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake();
|
||||
|
||||
$command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest');
|
||||
|
||||
expect($command)
|
||||
->toContain('-o ControlMaster=auto')
|
||||
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
|
||||
->toContain('-o ControlPersist=3600')
|
||||
->not->toContain('-O check')
|
||||
->not->toContain('ssh -fN');
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('returns false and runs no process when multiplexing is globally disabled', function () {
|
||||
config(['constants.ssh.mux_enabled' => false]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake();
|
||||
|
||||
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse();
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$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 attachDb(string $modelClass, array $extra, $destination, $environment)
|
||||
{
|
||||
return $modelClass::create(array_merge([
|
||||
'name' => 'test-'.strtolower(class_basename($modelClass)),
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
], $extra));
|
||||
}
|
||||
|
||||
test('StandaloneDocker::databases() includes attached keydb', function () {
|
||||
attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment);
|
||||
|
||||
expect($this->destination->databases()->count())->toBe(1);
|
||||
expect($this->destination->attachedTo())->toBeTrue();
|
||||
});
|
||||
|
||||
test('StandaloneDocker::databases() includes attached dragonfly', function () {
|
||||
attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment);
|
||||
|
||||
expect($this->destination->databases()->count())->toBe(1);
|
||||
expect($this->destination->attachedTo())->toBeTrue();
|
||||
});
|
||||
|
||||
test('StandaloneDocker::databases() includes attached clickhouse', function () {
|
||||
attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment);
|
||||
|
||||
expect($this->destination->databases()->count())->toBe(1);
|
||||
expect($this->destination->attachedTo())->toBeTrue();
|
||||
});
|
||||
|
||||
test('StandaloneDocker::databases() includes all 8 standalone database types', function () {
|
||||
attachDb(StandalonePostgresql::class, ['postgres_password' => 'pw'], $this->destination, $this->environment);
|
||||
attachDb(StandaloneRedis::class, ['redis_password' => 'pw'], $this->destination, $this->environment);
|
||||
attachDb(StandaloneMongodb::class, ['mongo_initdb_root_password' => 'pw'], $this->destination, $this->environment);
|
||||
attachDb(StandaloneMysql::class, ['mysql_root_password' => 'pw', 'mysql_password' => 'pw'], $this->destination, $this->environment);
|
||||
attachDb(StandaloneMariadb::class, ['mariadb_root_password' => 'pw', 'mariadb_password' => 'pw'], $this->destination, $this->environment);
|
||||
attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment);
|
||||
attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment);
|
||||
attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment);
|
||||
|
||||
expect($this->destination->databases()->count())->toBe(8);
|
||||
expect($this->destination->attachedTo())->toBeTrue();
|
||||
});
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Jobs\StripeProcessJob;
|
||||
use App\Jobs\SubscriptionInvoiceFailedJob;
|
||||
use App\Jobs\VerifyStripeSubscriptionStatusJob;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Internal\GeneralNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -228,3 +232,65 @@ describe('ServerLimitCheckJob dispatch is guarded by team check', function () {
|
||||
Queue::assertNotPushed(ServerLimitCheckJob::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing subscription Stripe webhooks are ignored', function () {
|
||||
test('does not send internal notifications or queue follow-up jobs', function (array $event) {
|
||||
Queue::fake();
|
||||
|
||||
$rootTeam = Team::factory()->create(['id' => 0]);
|
||||
$rootTeam->discordNotificationSettings()->update(['discord_enabled' => true]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
Notification::assertNotSentTo($rootTeam, GeneralNotification::class);
|
||||
Queue::assertNotPushed(SubscriptionInvoiceFailedJob::class);
|
||||
Queue::assertNotPushed(VerifyStripeSubscriptionStatusJob::class);
|
||||
})->with([
|
||||
'invoice paid' => [[
|
||||
'type' => 'invoice.paid',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_missing_invoice_paid',
|
||||
'amount_paid' => 1000,
|
||||
'subscription' => 'sub_missing_invoice_paid',
|
||||
'lines' => [
|
||||
'data' => [[
|
||||
'plan' => ['id' => 'price_dynamic_monthly'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
]],
|
||||
'invoice payment failed' => [[
|
||||
'type' => 'invoice.payment_failed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_missing_invoice_payment_failed',
|
||||
'id' => 'in_missing_invoice_payment_failed',
|
||||
'payment_intent' => null,
|
||||
],
|
||||
],
|
||||
]],
|
||||
'payment intent payment failed' => [[
|
||||
'type' => 'payment_intent.payment_failed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_missing_payment_intent_failed',
|
||||
],
|
||||
],
|
||||
]],
|
||||
'customer subscription deleted' => [[
|
||||
'type' => 'customer.subscription.deleted',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_missing_subscription_deleted',
|
||||
'id' => 'sub_missing_subscription_deleted',
|
||||
],
|
||||
],
|
||||
]],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use Illuminate\Contracts\Queue\Job;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Laravel\Horizon\Contracts\JobRepository;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
function fakeJob(string $uuid): Job
|
||||
{
|
||||
$job = Mockery::mock(Job::class)->shouldIgnoreMissing();
|
||||
$job->shouldReceive('uuid')->andReturn($uuid);
|
||||
$job->shouldReceive('getJobId')->andReturn($uuid);
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
function fireJobFailed(Job $job, Throwable $exception): void
|
||||
{
|
||||
event(new JobFailed('redis', $job, $exception));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
});
|
||||
|
||||
test('scrubs Horizon failed entry for DeploymentException on cloud', function () {
|
||||
$uuid = 'uuid-deployment-1';
|
||||
|
||||
$this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
|
||||
$mock->shouldReceive('deleteFailed')->once()->with($uuid);
|
||||
});
|
||||
|
||||
fireJobFailed(fakeJob($uuid), new DeploymentException('build failed'));
|
||||
});
|
||||
|
||||
test('scrubs Horizon failed entry for TimeoutExceededException on cloud', function () {
|
||||
$uuid = 'uuid-timeout-1';
|
||||
|
||||
$this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
|
||||
$mock->shouldReceive('deleteFailed')->once()->with($uuid);
|
||||
});
|
||||
|
||||
fireJobFailed(fakeJob($uuid), new TimeoutExceededException('worker timeout'));
|
||||
});
|
||||
|
||||
test('does not scrub generic exceptions on cloud', function () {
|
||||
$this->mock(JobRepository::class, function (MockInterface $mock) {
|
||||
$mock->shouldNotReceive('deleteFailed');
|
||||
});
|
||||
|
||||
fireJobFailed(fakeJob('uuid-generic-1'), new RuntimeException('boom'));
|
||||
});
|
||||
|
||||
test('does not scrub when self-hosted even for filtered exceptions', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
$this->mock(JobRepository::class, function (MockInterface $mock) {
|
||||
$mock->shouldNotReceive('deleteFailed');
|
||||
});
|
||||
|
||||
fireJobFailed(fakeJob('uuid-deployment-2'), new DeploymentException('build failed'));
|
||||
fireJobFailed(fakeJob('uuid-timeout-2'), new TimeoutExceededException('worker timeout'));
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function createSyncBunnyFailingBinary(string $binDir, string $name): void
|
||||
{
|
||||
file_put_contents("{$binDir}/{$name}", <<<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s %s\n' "$(basename "$0")" "$*" >> "$SYNC_BUNNY_TEST_LOG"
|
||||
exit 1
|
||||
SH);
|
||||
chmod("{$binDir}/{$name}", 0755);
|
||||
}
|
||||
|
||||
it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () {
|
||||
Http::fake([
|
||||
'storage.bunnycdn.com/*' => Http::response([], 201),
|
||||
'api.bunny.net/purge*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
|
||||
$logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
|
||||
|
||||
mkdir($binDir, 0755, true);
|
||||
createSyncBunnyFailingBinary($binDir, 'gh');
|
||||
createSyncBunnyFailingBinary($binDir, 'git');
|
||||
|
||||
$originalPath = getenv('PATH') ?: '';
|
||||
putenv("PATH={$binDir}:{$originalPath}");
|
||||
putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
|
||||
|
||||
try {
|
||||
$this->artisan('sync:bunny --release --nightly')
|
||||
->expectsConfirmation('Are you sure you want to proceed?', 'yes')
|
||||
->expectsOutputToContain('BunnyCDN sync: ✓ Complete')
|
||||
->doesntExpectOutputToContain('GitHub PR')
|
||||
->assertExitCode(0);
|
||||
} finally {
|
||||
putenv("PATH={$originalPath}");
|
||||
putenv('SYNC_BUNNY_TEST_LOG');
|
||||
}
|
||||
|
||||
expect(file_exists($logFile))->toBeFalse();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json');
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
|
||||
&& $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Storage\Resources as StorageResources;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->storageA = S3Storage::unguarded(fn () => S3Storage::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'storage-a-'.fake()->unique()->word(),
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key-a',
|
||||
'secret' => 'secret-a',
|
||||
'bucket' => 'bucket-a',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->teamA->id,
|
||||
]));
|
||||
|
||||
$this->storageB = S3Storage::unguarded(fn () => S3Storage::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'storage-b-'.fake()->unique()->word(),
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key-b',
|
||||
'secret' => 'secret-b',
|
||||
'bucket' => 'bucket-b',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->teamB->id,
|
||||
]));
|
||||
|
||||
$this->backupA = ScheduledDatabaseBackup::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'team_id' => $this->teamA->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => true,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_type' => 'App\\Models\\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
's3_storage_id' => $this->storageA->id,
|
||||
]);
|
||||
|
||||
$this->backupB = ScheduledDatabaseBackup::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'team_id' => $this->teamB->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => true,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_type' => 'App\\Models\\StandalonePostgresql',
|
||||
'database_id' => 2,
|
||||
's3_storage_id' => $this->storageB->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('Storage/Resources team-scoped backup access', function () {
|
||||
test('disableS3 on other team backup throws and leaves row unchanged', function () {
|
||||
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->call('disableS3', $this->backupB->id))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
|
||||
$this->backupB->refresh();
|
||||
expect((bool) $this->backupB->save_s3)->toBeTrue();
|
||||
expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
|
||||
});
|
||||
|
||||
test('moveBackup on other team backup throws and leaves row unchanged', function () {
|
||||
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->set('selectedStorages', [$this->backupB->id => $this->storageA->id])
|
||||
->call('moveBackup', $this->backupB->id))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
|
||||
$this->backupB->refresh();
|
||||
expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
|
||||
});
|
||||
|
||||
test('disableS3 on own backup succeeds', function () {
|
||||
Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->call('disableS3', $this->backupA->id);
|
||||
|
||||
$this->backupA->refresh();
|
||||
expect((bool) $this->backupA->save_s3)->toBeFalse();
|
||||
expect($this->backupA->s3_storage_id)->toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Destination\Show as DestinationShow;
|
||||
use App\Livewire\Project\New\DockerCompose;
|
||||
use App\Livewire\Project\New\DockerImage;
|
||||
use App\Livewire\Project\New\GithubPrivateRepository;
|
||||
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Livewire\Project\New\SimpleDockerfile;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverA->id,
|
||||
'name' => 'dest-a-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-a-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverB->id,
|
||||
'name' => 'dest-b-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-b-'.fake()->unique()->word(),
|
||||
]);
|
||||
$this->swarmDestinationB = SwarmDocker::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'swarm-b-'.fake()->unique()->word(),
|
||||
'network' => 'swarm-b-'.fake()->unique()->word(),
|
||||
'server_id' => $this->serverB->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('find_destination_for_current_team helper', function () {
|
||||
test('returns null for other team destination UUID', function () {
|
||||
expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for other team swarm destination UUID', function () {
|
||||
expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns own team destination', function () {
|
||||
$found = find_destination_for_current_team($this->destinationA->uuid);
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('returns null for blank uuid', function () {
|
||||
expect(find_destination_for_current_team(null))->toBeNull();
|
||||
expect(find_destination_for_current_team(''))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimpleDockerfile destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid);
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(SimpleDockerfile::class, $routeParams)
|
||||
->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n")
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerImage destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(DockerImage::class, $routeParams)
|
||||
->set('imageName', 'nginx')
|
||||
->set('imageTag', 'latest')
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
|
||||
test('submit with other team swarm destination throws', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid])
|
||||
->test(DockerImage::class, $routeParams)
|
||||
->set('imageName', 'nginx')
|
||||
->set('imageTag', 'latest')
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerCompose destination + server_id team scope', function () {
|
||||
test('submit with other team destination throws and creates no service', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Service::count();
|
||||
|
||||
Livewire::withUrlParams([
|
||||
'destination' => $this->destinationB->uuid,
|
||||
'server_id' => $this->serverB->id,
|
||||
])
|
||||
->test(DockerCompose::class, $routeParams)
|
||||
->set('dockerComposeRaw', "services:\n app:\n image: nginx\n")
|
||||
->call('submit');
|
||||
|
||||
expect(Service::count())->toBe($before);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('PublicGitRepository destination team scope', function () {
|
||||
test('submit with other team destination creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(PublicGitRepository::class, $routeParams)
|
||||
->set('repository_url', 'https://github.com/coollabsio/coolify')
|
||||
->set('git_repository', 'coollabsio/coolify')
|
||||
->set('git_branch', 'main')
|
||||
->set('port', 3000)
|
||||
->set('build_pack', 'nixpacks')
|
||||
->set('git_source', 'other')
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// submit wraps errors via handleError; count assertion below is source of truth
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GithubPrivateRepository destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(GithubPrivateRepository::class, $routeParams)
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GithubPrivateRepositoryDeployKey destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(GithubPrivateRepositoryDeployKey::class, $routeParams)
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource/Create database destination team scope', function () {
|
||||
test('mount with other team destination does not create database', function () {
|
||||
$before = StandalonePostgresql::count();
|
||||
|
||||
$url = route('project.resource.create', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine';
|
||||
|
||||
$this->get($url);
|
||||
|
||||
expect(StandalonePostgresql::count())->toBe($before);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () {
|
||||
test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () {
|
||||
expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () {
|
||||
expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () {
|
||||
$found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first();
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
|
||||
expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
|
||||
expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id);
|
||||
});
|
||||
|
||||
test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
|
||||
expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
|
||||
expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination/Show team scope', function () {
|
||||
test('mount with other team destination UUID redirects to index', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]);
|
||||
|
||||
expect($component->get('destination'))->toBeNull();
|
||||
$component->assertRedirect(route('destination.index'));
|
||||
});
|
||||
|
||||
test('mount with own destination UUID loads it', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]);
|
||||
|
||||
expect($component->get('destination'))->not->toBeNull();
|
||||
expect($component->get('destination')->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('mount with other team swarm destination UUID redirects to index', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]);
|
||||
|
||||
expect($component->get('destination'))->toBeNull();
|
||||
$component->assertRedirect(route('destination.index'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Team A (current actor)
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id, 'network' => 'net-a-'.fake()->uuid()]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Team B (other team)
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id, 'network' => 'net-b-'.fake()->uuid()]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
// Authenticate as Team A
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('unscoped Project lookup returns another teams project', function () {
|
||||
$project = Project::where('uuid', $this->projectB->uuid)->first();
|
||||
|
||||
expect($project)->not->toBeNull()
|
||||
->and($project->team_id)->toBe($this->teamB->id)
|
||||
->and($project->team_id)->not->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('unscoped StandaloneDocker lookup returns another teams destination', function () {
|
||||
$dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
|
||||
|
||||
expect($dest)->not->toBeNull()
|
||||
->and($dest->server->team_id)->toBe($this->teamB->id);
|
||||
});
|
||||
|
||||
test('ownedByCurrentTeam scope blocks other-team Project access', function () {
|
||||
expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('ownedByCurrentTeam scope allows own Project access', function () {
|
||||
expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull();
|
||||
});
|
||||
|
||||
test('Team A can create Application in Team B environment via unscoped lookups', function () {
|
||||
$destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
|
||||
$project = Project::where('uuid', $this->projectB->uuid)->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first();
|
||||
|
||||
$application = Application::create([
|
||||
'name' => 'team-scope-test-canary',
|
||||
'repository_project_id' => 0,
|
||||
'git_repository' => 'coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => "FROM alpine\nCMD echo hello",
|
||||
'ports_exposes' => 80,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'health_check_enabled' => false,
|
||||
'source_id' => 0,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
expect($application->environment_id)->toBe($this->environmentB->id)
|
||||
->and($application->destination_id)->toBe($this->destinationB->id)
|
||||
->and($application->environment->project->team->id)->toBe($this->teamB->id)
|
||||
->and($application->environment->project->team->id)->not->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('resource creation page loads with another teams project UUID', function () {
|
||||
$response = $this->get(route('project.resource.create', [
|
||||
'project_uuid' => $this->projectB->uuid,
|
||||
'environment_uuid' => $this->environmentB->uuid,
|
||||
]));
|
||||
|
||||
expect($response->status())->not->toBe(403);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Upgrade;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('initializes latest version during mount from cached versions data', function () {
|
||||
config(['constants.coolify.version' => '4.0.0-beta.998']);
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'new_version_available' => true,
|
||||
]);
|
||||
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
|
||||
->andReturn([
|
||||
'coolify' => [
|
||||
'v4' => [
|
||||
'version' => '4.0.0-beta.999',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Upgrade::class)
|
||||
->assertSet('currentVersion', '4.0.0-beta.998')
|
||||
->assertSet('latestVersion', '4.0.0-beta.999')
|
||||
->assertSet('isUpgradeAvailable', true)
|
||||
->assertSee('4.0.0-beta.998')
|
||||
->assertSee('4.0.0-beta.999');
|
||||
});
|
||||
|
||||
it('falls back to 0.0.0 during mount when cached versions data is unavailable', function () {
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'new_version_available' => false,
|
||||
]);
|
||||
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
|
||||
->andReturn(null);
|
||||
|
||||
Livewire::test(Upgrade::class)
|
||||
->assertSet('latestVersion', '0.0.0');
|
||||
});
|
||||
|
||||
it('clears stale upgrade availability when current version already matches latest version', function () {
|
||||
config(['constants.coolify.version' => '4.0.0-beta.999']);
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'new_version_available' => true,
|
||||
]);
|
||||
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
|
||||
->andReturn([
|
||||
'coolify' => [
|
||||
'v4' => [
|
||||
'version' => '4.0.0-beta.999',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Upgrade::class)
|
||||
->assertSet('latestVersion', '4.0.0-beta.999')
|
||||
->assertSet('isUpgradeAvailable', false);
|
||||
|
||||
expect(InstanceSettings::findOrFail(0)->new_version_available)->toBeFalse();
|
||||
});
|
||||
|
||||
it('clears stale upgrade availability when current version is newer than cached latest version', function () {
|
||||
config(['constants.coolify.version' => '4.0.0-beta.1000']);
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'new_version_available' => true,
|
||||
]);
|
||||
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
|
||||
->andReturn([
|
||||
'coolify' => [
|
||||
'v4' => [
|
||||
'version' => '4.0.0-beta.999',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Upgrade::class)
|
||||
->assertSet('latestVersion', '4.0.0-beta.999')
|
||||
->assertSet('isUpgradeAvailable', false);
|
||||
|
||||
expect(InstanceSettings::findOrFail(0)->new_version_available)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create root team and admin user (instance admin)
|
||||
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
|
||||
$this->adminUser = User::factory()->create();
|
||||
$this->rootTeam->members()->attach($this->adminUser->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->adminUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
});
|
||||
|
||||
it('deletes a user whose team has a github app with applications', function () {
|
||||
// Create the user to be deleted with their own team
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = $targetUser->teams()->first(); // created by User::created event
|
||||
|
||||
// Create a private key for the team
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $targetTeam->id]);
|
||||
|
||||
// Create a server and destination for the team
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
|
||||
// Create a project and environment
|
||||
$project = Project::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
// Create a GitHub App owned by the target team
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
// Create an application that uses the GitHub App as its source
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => $githubApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
// Delete the user — this should NOT throw a GithubApp exception
|
||||
$targetUser->delete();
|
||||
|
||||
// Assert user is deleted
|
||||
expect(User::find($targetUser->id))->toBeNull();
|
||||
|
||||
// Assert the GitHub App is deleted
|
||||
expect(GithubApp::find($githubApp->id))->toBeNull();
|
||||
|
||||
// Assert the application is deleted
|
||||
expect(Application::find($application->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('does not delete system-wide github apps when deleting a different team', function () {
|
||||
// Create a system-wide GitHub App owned by the root team
|
||||
$rootPrivateKey = PrivateKey::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$systemGithubApp = GithubApp::create([
|
||||
'name' => 'System GitHub App',
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'private_key_id' => $rootPrivateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
// Create a target user with their own team
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = $targetUser->teams()->first();
|
||||
|
||||
// Create an application on the target team that uses the system-wide GitHub App
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => $systemGithubApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
// Delete the target user — should NOT throw or delete the system-wide GitHub App
|
||||
$targetUser->delete();
|
||||
|
||||
// Assert user is deleted
|
||||
expect(User::find($targetUser->id))->toBeNull();
|
||||
|
||||
// Assert the system-wide GitHub App still exists
|
||||
expect(GithubApp::find($systemGithubApp->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('transfers instance-wide github app to root team when owning user is deleted', function () {
|
||||
// Create a user whose team owns an instance-wide GitHub App
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = $targetUser->teams()->first();
|
||||
|
||||
$targetPrivateKey = PrivateKey::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$instanceWideApp = GithubApp::create([
|
||||
'name' => 'Instance-Wide GitHub App',
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $targetPrivateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
// Create an application on the ROOT team that uses this instance-wide GitHub App
|
||||
$rootPrivateKey = PrivateKey::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$rootServer = Server::factory()->create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'private_key_id' => $rootPrivateKey->id,
|
||||
]);
|
||||
$rootDestination = StandaloneDocker::factory()->create(['server_id' => $rootServer->id]);
|
||||
$rootProject = Project::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$rootEnvironment = Environment::factory()->create(['project_id' => $rootProject->id]);
|
||||
|
||||
$otherTeamApp = Application::factory()->create([
|
||||
'environment_id' => $rootEnvironment->id,
|
||||
'destination_id' => $rootDestination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => $instanceWideApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
// Delete the user — should succeed and transfer the instance-wide app to root team
|
||||
$targetUser->delete();
|
||||
|
||||
// Assert user is deleted
|
||||
expect(User::find($targetUser->id))->toBeNull();
|
||||
|
||||
// Assert the instance-wide GitHub App is preserved and transferred to root team
|
||||
$instanceWideApp->refresh();
|
||||
expect($instanceWideApp)->not->toBeNull();
|
||||
expect($instanceWideApp->team_id)->toBe($this->rootTeam->id);
|
||||
|
||||
// Assert the other team's application still has its source intact
|
||||
$otherTeamApp->refresh();
|
||||
expect($otherTeamApp->source_id)->toBe($instanceWideApp->id);
|
||||
expect($otherTeamApp->source_type)->toBe(GithubApp::class);
|
||||
});
|
||||
|
||||
it('transfers instance-wide github app to root team when team is deleted directly', function () {
|
||||
// Create a team that owns an instance-wide GitHub App
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = $targetUser->teams()->first();
|
||||
|
||||
$targetPrivateKey = PrivateKey::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$instanceWideApp = GithubApp::create([
|
||||
'name' => 'Instance-Wide GitHub App',
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $targetPrivateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
// Create an application on the ROOT team that uses this instance-wide GitHub App
|
||||
$rootPrivateKey = PrivateKey::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$rootServer = Server::factory()->create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'private_key_id' => $rootPrivateKey->id,
|
||||
]);
|
||||
$rootDestination = StandaloneDocker::factory()->create(['server_id' => $rootServer->id]);
|
||||
$rootProject = Project::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$rootEnvironment = Environment::factory()->create(['project_id' => $rootProject->id]);
|
||||
|
||||
$otherTeamApp = Application::factory()->create([
|
||||
'environment_id' => $rootEnvironment->id,
|
||||
'destination_id' => $rootDestination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => $instanceWideApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
// Delete the team directly — should transfer instance-wide app to root team
|
||||
$targetTeam->delete();
|
||||
|
||||
// Assert the instance-wide GitHub App is preserved and transferred to root team
|
||||
$instanceWideApp->refresh();
|
||||
expect($instanceWideApp)->not->toBeNull();
|
||||
expect($instanceWideApp->team_id)->toBe($this->rootTeam->id);
|
||||
|
||||
// Assert the other team's application still has its source intact
|
||||
$otherTeamApp->refresh();
|
||||
expect($otherTeamApp->source_id)->toBe($instanceWideApp->id);
|
||||
expect($otherTeamApp->source_type)->toBe(GithubApp::class);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('attaches the root user as owner when reusing an existing root team', function () {
|
||||
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
|
||||
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
|
||||
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
|
||||
});
|
||||
|
||||
it('promotes the root user to owner when the reused root team pivot already exists', function () {
|
||||
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
|
||||
|
||||
DB::table('team_user')->insert([
|
||||
'team_id' => 0,
|
||||
'user_id' => 0,
|
||||
'role' => 'member',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
|
||||
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
|
||||
});
|
||||
@@ -0,0 +1,601 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createApplicationWithWebhook(string $repo = 'test-org/test-repo', string $branch = 'main', array $overrides = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
return Application::create(array_merge([
|
||||
'name' => 'webhook-test-app',
|
||||
'git_repository' => "https://github.com/{$repo}",
|
||||
'git_branch' => $branch,
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('GitHub Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_github' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_github;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitLab Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_gitlab' => null,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'attacker-supplied-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('rejects push with wrong token', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'wrong-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid token', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_gitlab;
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => $secret,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bitbucket Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_bitbucket' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('rejects push with non-sha256 algorithm', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_bitbucket;
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha1='.hash_hmac('sha1', $payload, $secret),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid sha256 hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_bitbucket;
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gitea Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_gitea' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_gitea;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manual Webhook Repository Matching', function () {
|
||||
test('github rejects empty repository without leaking applications', function () {
|
||||
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => ''],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->toContain('Invalid repository')
|
||||
->not->toContain('secret-github-app')
|
||||
->not->toContain($app->uuid);
|
||||
});
|
||||
|
||||
test('github does not match repository substrings', function () {
|
||||
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->toContain('No applications found')
|
||||
->not->toContain('secret-github-app')
|
||||
->not->toContain($app->uuid);
|
||||
});
|
||||
|
||||
test('github invalid signature does not leak matched application identifiers', function () {
|
||||
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->toContain('Invalid signature')
|
||||
->not->toContain('secret-github-app')
|
||||
->not->toContain($app->uuid)
|
||||
->not->toContain('application_uuid')
|
||||
->not->toContain('application_name');
|
||||
});
|
||||
|
||||
test('manual webhooks reject empty repositories for every provider without leaking applications', function (string $provider, string $uri, array $payload, array $headers) {
|
||||
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
|
||||
$body = json_encode($payload);
|
||||
|
||||
$server = ['CONTENT_TYPE' => 'application/json'];
|
||||
foreach ($headers as $name => $value) {
|
||||
$server[$name] = $value;
|
||||
}
|
||||
|
||||
$response = $this->call('POST', $uri, [], [], [], $server, $body);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->toContain('Invalid repository')
|
||||
->not->toContain("secret-{$provider}-app")
|
||||
->not->toContain($app->uuid);
|
||||
})->with([
|
||||
'gitlab' => [
|
||||
'gitlab',
|
||||
'/webhooks/source/gitlab/events/manual',
|
||||
[
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => ''],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
],
|
||||
['HTTP_X-Gitlab-Token' => 'wrong-token'],
|
||||
],
|
||||
'bitbucket' => [
|
||||
'bitbucket',
|
||||
'/webhooks/source/bitbucket/events/manual',
|
||||
[
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => ''],
|
||||
],
|
||||
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
|
||||
],
|
||||
'gitea' => [
|
||||
'gitea',
|
||||
'/webhooks/source/gitea/events/manual',
|
||||
[
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => ''],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
],
|
||||
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
|
||||
],
|
||||
]);
|
||||
|
||||
test('manual webhooks do not match repository substrings for every provider', function (string $provider, string $uri, array $payload, array $headers) {
|
||||
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
|
||||
$body = json_encode($payload);
|
||||
|
||||
$server = ['CONTENT_TYPE' => 'application/json'];
|
||||
foreach ($headers as $name => $value) {
|
||||
$server[$name] = $value;
|
||||
}
|
||||
|
||||
$response = $this->call('POST', $uri, [], [], [], $server, $body);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->toContain('No applications found')
|
||||
->not->toContain("secret-{$provider}-app")
|
||||
->not->toContain($app->uuid);
|
||||
})->with([
|
||||
'gitlab' => [
|
||||
'gitlab',
|
||||
'/webhooks/source/gitlab/events/manual',
|
||||
[
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
],
|
||||
['HTTP_X-Gitlab-Token' => 'wrong-token'],
|
||||
],
|
||||
'bitbucket' => [
|
||||
'bitbucket',
|
||||
'/webhooks/source/bitbucket/events/manual',
|
||||
[
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test'],
|
||||
],
|
||||
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
|
||||
],
|
||||
'gitea' => [
|
||||
'gitea',
|
||||
'/webhooks/source/gitea/events/manual',
|
||||
[
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
],
|
||||
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
|
||||
],
|
||||
]);
|
||||
|
||||
test('github matches ssh git repository URL exactly', function () {
|
||||
$app = createApplicationWithWebhook(overrides: [
|
||||
'git_repository' => 'git@github.com:test-org/test-repo.git',
|
||||
]);
|
||||
$secret = $app->manual_webhook_secret_github;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->not->toContain('No applications found');
|
||||
});
|
||||
|
||||
test('gitlab matches scp-style ssh repository URL with custom port', function () {
|
||||
$app = createApplicationWithWebhook(overrides: [
|
||||
'git_repository' => 'git@gitlab.example.com:2222/services/xyz.git',
|
||||
'git_branch' => 'master',
|
||||
]);
|
||||
$secret = $app->manual_webhook_secret_gitlab;
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/master',
|
||||
'project' => ['path_with_namespace' => 'services/xyz'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => $secret,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->not->toContain('No applications found');
|
||||
});
|
||||
|
||||
test('gitlab matches scp-style ssh repository URL without port', function () {
|
||||
$app = createApplicationWithWebhook(overrides: [
|
||||
'git_repository' => 'git@gitlab.example.com:services/xyz.git',
|
||||
'git_branch' => 'master',
|
||||
]);
|
||||
$secret = $app->manual_webhook_secret_gitlab;
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/master',
|
||||
'project' => ['path_with_namespace' => 'services/xyz'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => $secret,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->not->toContain('No applications found');
|
||||
});
|
||||
|
||||
test('github matches repository case-insensitively', function () {
|
||||
$app = createApplicationWithWebhook(overrides: [
|
||||
'git_repository' => 'https://github.com/Test-Org/Test-Repo.git',
|
||||
]);
|
||||
$secret = $app->manual_webhook_secret_github;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->not->toContain('No applications found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Secret Auto-Generation', function () {
|
||||
test('auto-generates webhook secrets on application creation', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
expect($app->manual_webhook_secret_github)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_gitlab)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_bitbucket)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_gitea)->not->toBeEmpty();
|
||||
expect(strlen($app->manual_webhook_secret_github))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_gitlab))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_bitbucket))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_gitea))->toBe(40);
|
||||
});
|
||||
|
||||
test('encrypts webhook secrets at rest', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$plaintext = $app->manual_webhook_secret_github;
|
||||
|
||||
$raw = DB::table('applications')->where('id', $app->id)->first();
|
||||
|
||||
expect($raw->manual_webhook_secret_github)->not->toBe($plaintext);
|
||||
expect($app->manual_webhook_secret_github)->toBe($plaintext);
|
||||
});
|
||||
});
|
||||
@@ -437,6 +437,16 @@ it('deletes all build images when retention is disabled', function () {
|
||||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
|
||||
});
|
||||
|
||||
it('container prune excludes persistent resource types', function () {
|
||||
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
|
||||
|
||||
expect($sourceFile)->toContain('label!=coolify.type=database');
|
||||
expect($sourceFile)->toContain('label!=coolify.type=application');
|
||||
expect($sourceFile)->toContain('label!=coolify.type=service');
|
||||
expect($sourceFile)->toContain('label!=coolify.proxy=true');
|
||||
expect($sourceFile)->toContain('label=coolify.managed=true');
|
||||
});
|
||||
|
||||
it('preserves build image for currently running tag', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
public array $recordedCommands = [];
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function execute_remote_command(...$commands)
|
||||
{
|
||||
$this->recordedCommands[] = $commands;
|
||||
}
|
||||
}
|
||||
|
||||
function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array
|
||||
{
|
||||
$job = new TestableRailpackDeploymentJob;
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
|
||||
$application = new Application($applicationAttributes);
|
||||
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'workdir' => '/artifacts/test-app',
|
||||
'deployment_uuid' => 'deployment-uuid',
|
||||
'saved_outputs' => new Collection($savedOutputs),
|
||||
'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'",
|
||||
'force_rebuild' => false,
|
||||
'addHosts' => '',
|
||||
'secrets_hash_key' => 'testing-app-key',
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
return [$job, $reflection];
|
||||
}
|
||||
|
||||
function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed
|
||||
{
|
||||
$reflectionMethod = $reflection->getMethod($method);
|
||||
$reflectionMethod->setAccessible(true);
|
||||
|
||||
return $reflectionMethod->invokeArgs($job, $arguments);
|
||||
}
|
||||
|
||||
it('deep merges repository railpack config with coolify overrides', function () {
|
||||
$repositoryConfigJson = json_encode([
|
||||
'$schema' => 'https://schema.railpack.com',
|
||||
'packages' => [
|
||||
'node' => '20',
|
||||
],
|
||||
'steps' => [
|
||||
'build' => [
|
||||
'inputs' => [['step' => 'install']],
|
||||
'commands' => ['npm run build'],
|
||||
],
|
||||
],
|
||||
'deploy' => [
|
||||
'variables' => [
|
||||
'NODE_ENV' => 'production',
|
||||
],
|
||||
'startCommand' => 'node index.js',
|
||||
],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
[$job, $reflection] = makeRailpackDeploymentJob(
|
||||
[
|
||||
'install_command' => 'npm ci',
|
||||
'build_command' => 'npm run build:prod',
|
||||
'start_command' => 'node server.js',
|
||||
],
|
||||
[
|
||||
'railpack_config_exists' => 'exists',
|
||||
'railpack_repository_config' => $repositoryConfigJson,
|
||||
],
|
||||
);
|
||||
|
||||
$repositoryConfig = invokeRailpackMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'decode_railpack_config',
|
||||
[$repositoryConfigJson, 'repository railpack.json'],
|
||||
);
|
||||
$overrides = [
|
||||
'deploy' => [
|
||||
'variables' => [
|
||||
'APP_ENV' => 'production',
|
||||
],
|
||||
],
|
||||
'packages' => [
|
||||
'python' => '3.13',
|
||||
],
|
||||
];
|
||||
$generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]);
|
||||
|
||||
expect($generatedConfig)->toMatchArray([
|
||||
'$schema' => 'https://schema.railpack.com',
|
||||
'packages' => [
|
||||
'node' => '20',
|
||||
'python' => '3.13',
|
||||
],
|
||||
'steps' => [
|
||||
'build' => [
|
||||
'inputs' => [['step' => 'install']],
|
||||
'commands' => ['npm run build'],
|
||||
],
|
||||
],
|
||||
'deploy' => [
|
||||
'variables' => [
|
||||
'NODE_ENV' => 'production',
|
||||
'APP_ENV' => 'production',
|
||||
],
|
||||
'startCommand' => 'node index.js',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes a generated railpack config file when repository config exists', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob(
|
||||
['build_command' => 'npm run build'],
|
||||
[
|
||||
'railpack_config_exists' => 'exists',
|
||||
'railpack_repository_config' => json_encode([
|
||||
'$schema' => 'https://schema.railpack.com',
|
||||
'steps' => [
|
||||
'build' => [
|
||||
'commands' => ['npm run build'],
|
||||
],
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
],
|
||||
);
|
||||
|
||||
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
|
||||
|
||||
expect($configPath)->toBe('.coolify/railpack.generated.json');
|
||||
expect($job->recordedCommands)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('does not generate a railpack config file for command overrides alone', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([
|
||||
'install_command' => 'npm ci',
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
]);
|
||||
|
||||
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
|
||||
|
||||
expect($configPath)->toBeNull();
|
||||
expect($job->recordedCommands)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('fails fast when repository railpack config is invalid json', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob(
|
||||
['build_command' => 'npm run build'],
|
||||
[
|
||||
'railpack_config_exists' => 'exists',
|
||||
'railpack_repository_config' => '{"steps":{"build":',
|
||||
],
|
||||
);
|
||||
|
||||
expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'))
|
||||
->toThrow(DeploymentException::class, 'Invalid repository railpack.json');
|
||||
});
|
||||
|
||||
it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob(
|
||||
[
|
||||
'install_command' => 'npm ci',
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
],
|
||||
);
|
||||
$envRailpackArgsProperty = $reflection->getProperty('env_railpack_args');
|
||||
$envRailpackArgsProperty->setAccessible(true);
|
||||
$envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'");
|
||||
|
||||
$command = invokeRailpackMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'railpack_prepare_command',
|
||||
['.coolify/railpack.generated.json'],
|
||||
);
|
||||
|
||||
expect($command)->toContain('railpack prepare');
|
||||
expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
|
||||
expect($command)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci'");
|
||||
expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build'));
|
||||
expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js'));
|
||||
expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json'));
|
||||
expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app');
|
||||
expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD=");
|
||||
expect($command)->not->toContain("--env 'RAILPACK_START_CMD=");
|
||||
expect($command)->not->toContain('RAILPACK_BUILD_CMD=');
|
||||
expect($command)->not->toContain('RAILPACK_START_CMD=');
|
||||
});
|
||||
|
||||
it('fails fast when docker buildx is unavailable for railpack builds', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob();
|
||||
|
||||
$dockerBuildxAvailableProperty = $reflection->getProperty('dockerBuildxAvailable');
|
||||
$dockerBuildxAvailableProperty->setAccessible(true);
|
||||
$dockerBuildxAvailableProperty->setValue($job, false);
|
||||
|
||||
expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_docker_buildx_available_for_railpack'))
|
||||
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
|
||||
});
|
||||
|
||||
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([
|
||||
'uuid' => 'application-uuid',
|
||||
]);
|
||||
|
||||
$command = invokeRailpackMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'railpack_build_command',
|
||||
[
|
||||
'coollabsio/coolify:test',
|
||||
collect([
|
||||
'RAILPACK_NODE_VERSION' => '22',
|
||||
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
|
||||
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
|
||||
'SECRET_JSON' => '{"token":"abc"}',
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
expect($command)->toContain("env 'RAILPACK_NODE_VERSION=22'");
|
||||
expect($command)->toContain("'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
|
||||
expect($command)->toContain("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
|
||||
expect($command)->toContain("'SECRET_JSON={\"token\":\"abc\"}'");
|
||||
expect($command)->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'");
|
||||
expect($command)->toContain("--secret 'id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'");
|
||||
expect($command)->toContain("--secret 'id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'");
|
||||
expect($command)->toContain("--secret 'id=SECRET_JSON,env=SECRET_JSON'");
|
||||
expect($command)->toContain(' --build-arg secrets-hash=');
|
||||
expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"');
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Server;
|
||||
|
||||
it('generates escaped railpack env args from resolved values and includes install command', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall');
|
||||
|
||||
$nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$nodeVersion->forceFill([
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22');
|
||||
|
||||
$literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$literalValue->forceFill([
|
||||
'key' => 'RAILPACK_CUSTOM_FLAG',
|
||||
'is_literal' => true,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'");
|
||||
|
||||
$jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$jsonValue->forceFill([
|
||||
'key' => 'RAILPACK_JSON',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}');
|
||||
|
||||
$nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$nullValue->forceFill([
|
||||
'key' => 'RAILPACK_NULL',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null);
|
||||
|
||||
$envQuery = Mockery::mock();
|
||||
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
|
||||
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
|
||||
|
||||
$railpackQuery = Mockery::mock();
|
||||
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue]));
|
||||
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
$job->shouldAllowMockingProtectedMethods();
|
||||
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, $application);
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 0);
|
||||
|
||||
$mainServerProperty = $reflection->getProperty('mainServer');
|
||||
$mainServerProperty->setAccessible(true);
|
||||
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
|
||||
|
||||
$method = $reflection->getMethod('generate_railpack_env_variables');
|
||||
$method->setAccessible(true);
|
||||
$variables = $method->invoke($job);
|
||||
|
||||
$envArgsProperty = $reflection->getProperty('env_railpack_args');
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
expect($variables->all())->toBe([
|
||||
'RAILPACK_NODE_VERSION' => '22',
|
||||
'RAILPACK_CUSTOM_FLAG' => 'hello world',
|
||||
'RAILPACK_JSON' => '{"token":"abc"}',
|
||||
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
|
||||
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
|
||||
]);
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
|
||||
expect($envArgs)->not->toContain('RAILPACK_NULL');
|
||||
});
|
||||
|
||||
it('uses preview railpack environment variables for preview deployments', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
|
||||
|
||||
$previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$previewValue->forceFill([
|
||||
'key' => 'RAILPACK_PREVIEW_ONLY',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value');
|
||||
|
||||
$previewQuery = Mockery::mock();
|
||||
$previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
|
||||
$previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
|
||||
$previewQuery->shouldReceive('get')->once()->andReturn(collect([]));
|
||||
$application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery);
|
||||
|
||||
$railpackPreviewQuery = Mockery::mock();
|
||||
$railpackPreviewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue]));
|
||||
$application->shouldReceive('railpack_environment_variables_preview')->once()->andReturn($railpackPreviewQuery);
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
$job->shouldAllowMockingProtectedMethods();
|
||||
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, $application);
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 42);
|
||||
|
||||
$mainServerProperty = $reflection->getProperty('mainServer');
|
||||
$mainServerProperty->setAccessible(true);
|
||||
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
|
||||
|
||||
$method = $reflection->getMethod('generate_railpack_env_variables');
|
||||
$method->setAccessible(true);
|
||||
$variables = $method->invoke($job);
|
||||
|
||||
expect($variables->all())->toBe([
|
||||
'RAILPACK_PREVIEW_ONLY' => 'preview-value',
|
||||
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges coolify env variables into railpack build variables', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
|
||||
|
||||
$userVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$userVar->forceFill([
|
||||
'key' => 'MY_BUILD_VAR',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello');
|
||||
|
||||
$envQuery = Mockery::mock();
|
||||
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar]));
|
||||
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
|
||||
|
||||
$railpackQuery = Mockery::mock();
|
||||
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([]));
|
||||
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
$job->shouldAllowMockingProtectedMethods();
|
||||
$job->shouldReceive('generate_coolify_env_variables')
|
||||
->with(true)
|
||||
->andReturn(collect([
|
||||
'COOLIFY_URL' => 'https://app.example.com',
|
||||
'COOLIFY_FQDN' => 'app.example.com',
|
||||
'COOLIFY_BRANCH' => 'main',
|
||||
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
|
||||
'SOURCE_COMMIT' => 'abc123',
|
||||
'EMPTY_VAR' => '',
|
||||
'NULL_VAR' => null,
|
||||
]));
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, $application);
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 0);
|
||||
|
||||
$mainServerProperty = $reflection->getProperty('mainServer');
|
||||
$mainServerProperty->setAccessible(true);
|
||||
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
|
||||
|
||||
$method = $reflection->getMethod('generate_railpack_env_variables');
|
||||
$method->setAccessible(true);
|
||||
$variables = $method->invoke($job);
|
||||
|
||||
expect($variables->all())->toBe([
|
||||
'MY_BUILD_VAR' => 'hello',
|
||||
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
|
||||
'COOLIFY_URL' => 'https://app.example.com',
|
||||
'COOLIFY_FQDN' => 'app.example.com',
|
||||
'COOLIFY_BRANCH' => 'main',
|
||||
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
|
||||
'SOURCE_COMMIT' => 'abc123',
|
||||
]);
|
||||
|
||||
$envArgsProperty = $reflection->getProperty('env_railpack_args');
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'");
|
||||
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
|
||||
expect($envArgs)->not->toContain('EMPTY_VAR');
|
||||
expect($envArgs)->not->toContain('NULL_VAR');
|
||||
});
|
||||
|
||||
it('preserves user railpack deploy apt packages while adding healthcheck tools once', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
|
||||
|
||||
$deployPackages = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$deployPackages->forceFill([
|
||||
'key' => 'RAILPACK_DEPLOY_APT_PACKAGES',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$deployPackages->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('ffmpeg curl');
|
||||
|
||||
$envQuery = Mockery::mock();
|
||||
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
|
||||
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
|
||||
|
||||
$railpackQuery = Mockery::mock();
|
||||
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$deployPackages]));
|
||||
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
$job->shouldAllowMockingProtectedMethods();
|
||||
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, $application);
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 0);
|
||||
|
||||
$mainServerProperty = $reflection->getProperty('mainServer');
|
||||
$mainServerProperty->setAccessible(true);
|
||||
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
|
||||
|
||||
$method = $reflection->getMethod('generate_railpack_env_variables');
|
||||
$method->setAccessible(true);
|
||||
$variables = $method->invoke($job);
|
||||
|
||||
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget');
|
||||
|
||||
$envArgsProperty = $reflection->getProperty('env_railpack_args');
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'");
|
||||
});
|
||||
Loaded 100 of 583 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user