Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai committed 2026-08-08 11:26:20 +02:00
commit 003e44d123
1274 files changed
+156179 -25249

No files matched your search

@@ -0,0 +1,229 @@
<?php
use App\Http\Middleware\AddServerTimingHeaders;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
test('adds Server-Timing and debug headers when server_timing is enabled', function () {
Config::set('app.server_timing', true);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/__server-timing-probe', 'GET');
$response = $middleware->handle($request, function () {
DB::select('select 1');
return response('ok', 200);
});
expect($response->getStatusCode())->toBe(200);
expect($response->headers->has('Server-Timing'))->toBeTrue();
$serverTiming = $response->headers->get('Server-Timing');
expect($serverTiming)
->toContain('app;desc="Total";dur=')
->toContain('db;desc="Database (')
->toContain('php;desc="PHP (excl. DB)";dur=')
->toContain('dbslow;desc="Slowest query";dur=')
->toContain('queries;desc="Query count";dur=')
->toContain('html;desc="Response bytes";dur=')
->toContain('mem;desc="Peak memory (MB)";dur=');
expect($response->headers->has('X-Debug-Query-Count'))->toBeTrue();
expect((int) $response->headers->get('X-Debug-Query-Count'))->toBeGreaterThanOrEqual(1);
expect($serverTiming)->toMatch('/queries;desc="Query count";dur=\d+/');
expect($response->headers->has('X-Debug-Memory-MB'))->toBeTrue();
expect((float) $response->headers->get('X-Debug-Memory-MB'))->toBeGreaterThan(0);
expect($response->headers->has('X-Debug-Html-Bytes'))->toBeTrue();
expect((int) $response->headers->get('X-Debug-Html-Bytes'))->toBeGreaterThan(0);
expect($serverTiming)->toMatch('/html;desc="Response bytes";dur=\d+/');
});
test('injects on-screen HUD into full HTML documents when server_timing is enabled', function () {
Config::set('app.server_timing', true);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/projects', 'GET');
$response = $middleware->handle($request, function () {
DB::select('select 1');
return response('<!DOCTYPE html><html><body><main>projects</main></body></html>', 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
});
$body = $response->getContent();
expect($body)
->toContain('id="server-timing-hud"')
->toContain('data-metrics=')
->toContain('data-sth-log')
->toContain('data-sth-clear')
->toContain('data-sth-toggle')
->toContain('Server Timing')
->toContain('toggleOpen')
->toContain('formatEntryForAi')
->toContain('copyEntryById')
->toContain('__coolifyServerTimingHistory')
->toContain('</body>');
expect(substr_count($body, 'id="server-timing-hud"'))->toBe(1);
// Docks into main top bar via #server-timing-hud-slot; float fallback is bottom-left.
expect($body)
->toContain('dockHud')
->toContain('data-sth-mode="float"')
->toContain('position:fixed;bottom:12px;left:12px')
->not->toContain('position:fixed;bottom:12px;right:12px')
->not->toContain('position:fixed;top:56px;right:12px');
// Inline <script> must not embed literal HTML closers (browser ends the script early).
$hudScript = null;
if (preg_match('/id="server-timing-hud".*?<script data-navigate-once>(.*?)<\/script>/s', $body, $m)) {
$hudScript = $m[1];
}
expect($hudScript)->not->toBeNull();
expect($hudScript)
->not->toContain('</body>')
->not->toContain('</BODY>')
->not->toContain('</script>')
->not->toContain('</SCRIPT>');
});
test('removes stale Content-Length after injecting the HUD', function () {
Config::set('app.server_timing', true);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/projects', 'GET');
$html = '<!DOCTYPE html><html><body><main>projects</main></body></html>';
$response = $middleware->handle($request, function () use ($html) {
return response($html, 200, [
'Content-Type' => 'text/html; charset=UTF-8',
'Content-Length' => (string) strlen($html),
]);
});
expect($response->getContent())->toContain('id="server-timing-hud"');
expect($response->headers->has('Content-Length'))->toBeFalse();
});
test('app shell exposes Server-Timing HUD dock slots in desktop and mobile top bars', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('id="server-timing-hud-slot"')
->toContain('id="server-timing-hud-slot-mobile"')
->toContain('data-server-timing-hud-slot');
});
test('Server-Timing HUD docks into navbar slots and floats only as fallback', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
expect($hud)
->toContain('server-timing-hud-slot')
->toContain('server-timing-hud-slot-mobile')
->toContain("matchMedia('(min-width: 1024px)')")
->toContain('floats bottom-left only if no navbar slot is available')
// Mobile pill is compact (app ms only); full "ST · db · q" breakdown stays desktop/float.
->toContain("parentElement.id === 'server-timing-hud-slot-mobile'")
->toContain('compactSummary');
});
test('Server-Timing HUD follows the application color mode', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
expect($hud)
->toContain('#server-timing-hud {')
->toContain('html.dark #server-timing-hud {')
->toContain('--sth-background: rgba(255, 255, 255, .96)')
->toContain('--sth-background: rgba(16, 16, 16, .96)')
->toContain('var(--sth-text)')
->toContain('var(--sth-border)')
->toContain('[data-sth-log]::-webkit-scrollbar-thumb')
->toContain('scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track)');
});
test('does not inject HUD into non-HTML or fragment responses', function () {
Config::set('app.server_timing', true);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/api/probe', 'GET');
$json = $middleware->handle($request, function () {
return response()->json(['ok' => true]);
});
expect($json->headers->has('Server-Timing'))->toBeTrue();
expect($json->getContent())->not->toContain('server-timing-hud');
$fragment = $middleware->handle($request, function () {
return response('<div wire:id="x">partial</div>', 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
});
expect($fragment->getContent())->not->toContain('server-timing-hud');
});
test('does not add Server-Timing headers when server_timing is disabled', function () {
Config::set('app.server_timing', false);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/__server-timing-probe', 'GET');
$response = $middleware->handle($request, function () {
return response('<!DOCTYPE html><html><body>x</body></html>', 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
});
expect($response)->toBeInstanceOf(Response::class);
expect($response->headers->has('Server-Timing'))->toBeFalse();
expect($response->headers->has('X-Debug-Query-Count'))->toBeFalse();
expect($response->headers->has('X-Debug-Memory-MB'))->toBeFalse();
expect($response->getContent())->not->toContain('server-timing-hud');
});
test('server_timing defaults off outside the local environment', function () {
// Mirror phpunit APP_ENV=testing — config is resolved at boot; re-evaluate the default rule.
expect(config('app.env'))->not->toBe('local');
// When SERVER_TIMING_ENABLED is unset, non-local envs must not enable headers by default.
// (phpunit does not set SERVER_TIMING_ENABLED; APP_ENV=testing)
expect(config('app.server_timing'))->toBeFalse();
});
test('SERVER_TIMING_ENABLED can force server_timing on or off regardless of APP_ENV', function () {
// Mirrors config/app.php resolution for app.server_timing.
$resolve = function (?string $enabled, string $appEnv): bool {
return $enabled !== null
? (bool) filter_var($enabled, FILTER_VALIDATE_BOOLEAN)
: $appEnv === 'local';
};
expect($resolve(null, 'local'))->toBeTrue();
expect($resolve(null, 'production'))->toBeFalse();
expect($resolve(null, 'testing'))->toBeFalse();
// Force on in production / staging.
expect($resolve('true', 'production'))->toBeTrue();
expect($resolve('1', 'production'))->toBeTrue();
expect($resolve('yes', 'staging'))->toBeTrue();
// Force off even in local.
expect($resolve('false', 'local'))->toBeFalse();
expect($resolve('0', 'local'))->toBeFalse();
expect($resolve('off', 'local'))->toBeFalse();
});
test('server timing HUD can be toggled from the development command center', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
$commandCenter = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
expect($hud)
->toContain('coolify.serverTimingHud.enabled')
->toContain('server-timing-hud-visibility-changed')
->and($commandCenter)
->toContain("app()->environment('local')")
->toContain('Toggle Server Timing HUD')
->toContain("localStorage.setItem('coolify.serverTimingHud.enabled'")
->toContain("window.dispatchEvent(new CustomEvent('server-timing-hud-visibility-changed'))");
});
@@ -0,0 +1,47 @@
<?php
use App\Livewire\Admin\Index as AdminIndex;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('admin search only shows users with paid subscriptions as active', function () {
config()->set('cache.default', 'array');
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::unguarded(
fn () => InstanceSettings::query()->firstOrCreate(['id' => 0])
);
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$rootUser = User::find(0) ?? User::factory()->create(['id' => 0]);
$rootTeam->members()->syncWithoutDetaching([
$rootUser->id => ['role' => 'admin'],
]);
$inactiveUser = User::factory()->create(['email' => 'inactive@example.com']);
$inactiveTeam = Team::factory()->create();
$inactiveTeam->members()->attach($inactiveUser->id, ['role' => 'owner']);
Subscription::create([
'team_id' => $inactiveTeam->id,
'stripe_subscription_id' => 'sub_stale',
'stripe_invoice_paid' => false,
]);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->set([
'foundUsers' => collect(),
'search' => 'inactive@example.com',
])
->call('submitSearch')
->assertSee('No')
->assertDontSee('Yes');
});
@@ -0,0 +1,51 @@
<?php
/**
* Advanced navigation and action menus must share the same reicon ("grid").
*/
test('advanced sidebar and configuration menus use the grid icon', function () {
$files = [
resource_path('views/components/settings/layout.blade.php'),
resource_path('views/components/server/sidebar.blade.php'),
resource_path('views/components/service-database/sidebar.blade.php'),
resource_path('views/livewire/project/service/index.blade.php'),
resource_path('views/livewire/project/application/configuration.blade.php'),
];
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)->toContain('Advanced');
// PHP menu arrays: label Advanced paired with icon grid
if (str_contains($contents, "'label' => 'Advanced'") || str_contains($contents, "'Advanced' =>")) {
expect($contents)
->toMatch("/'label'\\s*=>\\s*'Advanced'[\\s\\S]{0,160}?'icon'\\s*=>\\s*'grid'|'Advanced'\\s*=>\\s*'grid'/")
->not->toMatch("/'Advanced'\\s*=>\\s*'(?!grid)[^']+'/");
}
// Inline blade markup: reicon grid near Advanced label
if (str_contains($contents, 'menu-item-label">Advanced')) {
expect($contents)
->toMatch('/name="grid"[\\s\\S]{0,120}menu-item-label">Advanced/')
->not->toMatch('/name="(?!grid)[^"]+"[\\s\\S]{0,120}menu-item-label">Advanced/');
}
}
});
test('advanced action dropdown menus use the grid icon', function () {
$files = [
resource_path('views/components/applications/advanced.blade.php'),
resource_path('views/components/services/advanced.blade.php'),
];
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)
->toContain('Advanced')
->toContain('name="grid"')
->not->toContain('name="sliders"')
->not->toContain("name='sliders'");
}
});
@@ -0,0 +1,21 @@
<?php
use App\Http\Controllers\Api\ApplicationsController;
function currentRollbackImageTag(string $imageReference): ?string
{
$method = new ReflectionMethod(ApplicationsController::class, 'currentRollbackImageTag');
$method->setAccessible(true);
return $method->invoke(null, $imageReference);
}
it('extracts the current rollback image tag when the registry includes a port', function () {
expect(currentRollbackImageTag('registry.example.com:5000/team/application:commit-sha'))
->toBe('commit-sha');
});
it('does not treat a digest as the current rollback image tag', function () {
expect(currentRollbackImageTag('registry.example.com:5000/team/application@sha256:'.str_repeat('a', 64)))
->toBeNull();
});
@@ -57,6 +57,14 @@ function recommendedApplicationSettingsPayload(): array
'is_gzip_enabled' => false,
'is_stripprefix_enabled' => false,
'is_raw_compose_deployment_enabled' => true,
'is_log_drain_enabled' => true,
'is_gpu_enabled' => true,
'gpu_driver' => 'nvidia',
'gpu_count' => '1',
'gpu_device_ids' => '0',
'gpu_options' => null,
'is_consistent_container_name_enabled' => true,
'custom_internal_name' => 'my-app-internal',
];
}
@@ -181,3 +189,122 @@ test('raw compose deployment can only be enabled for Docker Compose applications
->assertUnprocessable()
->assertJsonValidationErrors('is_raw_compose_deployment_enabled');
});
function advancedApplicationSettingsPayload(): array
{
return [
'is_log_drain_enabled' => true,
'is_gpu_enabled' => true,
'gpu_driver' => 'nvidia',
'gpu_count' => '1',
'gpu_device_ids' => '0',
'gpu_options' => 'capabilities=compute,utility',
'is_consistent_container_name_enabled' => true,
'custom_internal_name' => 'my-app-container',
];
}
test('PATCH /api/v1/applications/{uuid} updates advanced application settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", advancedApplicationSettingsPayload())
->assertOk();
$settings = $this->application->fresh()->settings;
foreach (advancedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
});
test('PATCH /api/v1/applications/{uuid} updates preview_url_template and max_restart_count', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'preview_url_template' => '{{pr_id}}.preview.example.com',
'max_restart_count' => 5,
])
->assertOk();
$application = $this->application->fresh();
expect($application->preview_url_template)->toBe('{{pr_id}}.preview.example.com')
->and($application->max_restart_count)->toBe(5);
});
test('GET /api/v1/applications/{uuid} includes advanced settings', function () {
$this->application->settings->update(advancedApplicationSettingsPayload());
$this->application->update([
'preview_url_template' => '{{pr_id}}.preview.example.com',
'max_restart_count' => 3,
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertOk()
->assertJsonPath('settings.is_log_drain_enabled', true)
->assertJsonPath('settings.is_gpu_enabled', true)
->assertJsonPath('settings.gpu_driver', 'nvidia')
->assertJsonPath('settings.custom_internal_name', 'my-app-container')
->assertJsonPath('settings.is_consistent_container_name_enabled', true)
->assertJsonPath('preview_url_template', '{{pr_id}}.preview.example.com')
->assertJsonPath('max_restart_count', 3);
});
test('application creation accepts advanced application settings', function () {
Queue::fake();
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', array_merge([
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/advanced-settings-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'autogenerate_domain' => false,
'preview_url_template' => '{{pr_id}}.create.example.com',
'max_restart_count' => 7,
], advancedApplicationSettingsPayload()))
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
$settings = $application->settings;
foreach (advancedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
expect($application->preview_url_template)->toBe('{{pr_id}}.create.example.com')
->and($application->max_restart_count)->toBe(7);
});
test('rejects invalid max_restart_count', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'max_restart_count' => -1,
])
->assertUnprocessable()
->assertJsonValidationErrors('max_restart_count');
});
test('rejects invalid gpu boolean settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_gpu_enabled' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('is_gpu_enabled');
});
test('rejects swarm fields on application update', function (string $field, mixed $value) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
$field => $value,
])
->assertUnprocessable()
->assertJsonValidationErrors($field);
})->with([
'swarm_replicas' => ['swarm_replicas', 3],
'swarm_placement_constraints' => ['swarm_placement_constraints', 'node.role==worker'],
'is_swarm_only_worker_nodes' => ['is_swarm_only_worker_nodes', true],
]);
@@ -5,8 +5,10 @@ use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -15,7 +17,7 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -77,6 +79,56 @@ function backupHeaders(): array
}
describe('POST /api/v1/databases/{uuid}/backups', function () {
test('rejects backup configurations for unsupported database types', function () {
$database = StandaloneRedis::create([
'uuid' => (string) Str::uuid(),
'name' => 'Redis DB',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertUnprocessable()
->assertJson([
'message' => 'Scheduled backups are not supported for this database type.',
]);
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
test('defaults clickhouse backups to its configured database', function () {
$database = StandaloneClickhouse::create([
'uuid' => (string) Str::uuid(),
'name' => 'ClickHouse DB',
'clickhouse_admin_user' => 'default',
'clickhouse_admin_password' => 'password',
'clickhouse_db' => 'analytics',
'image' => 'clickhouse/clickhouse-server:25.11',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertCreated();
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->firstOrFail();
expect($backup->databases_to_backup)->toBe('analytics')
->and($backup->database_type)->toBe(StandaloneClickhouse::class);
});
test('creates backup configuration with valid frequency', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
+73
View File
@@ -245,6 +245,79 @@ describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
});
});
describe('PATCH /api/v1/destinations/{uuid}', function () {
test('updates destination name only', function () {
$originalNetwork = $this->destination->network;
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
'name' => 'Renamed Destination',
]);
$response->assertOk()
->assertJson([
'uuid' => $this->destination->uuid,
'name' => 'Renamed Destination',
'network' => $originalNetwork,
'type' => 'standalone',
'server_uuid' => $this->server->uuid,
]);
$this->destination->refresh();
expect($this->destination->name)->toBe('Renamed Destination')
->and($this->destination->network)->toBe($originalNetwork);
});
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
'name' => 'Should Fail',
]);
$response->assertForbidden();
});
test('rejects update requests from non-admin team members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = destinationsApiToken($member, $this->team, ['*']);
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
'name' => 'Member Rename',
]);
$response->assertForbidden();
});
test('rejects network changes and unknown fields', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
'name' => 'Valid Name',
'network' => 'new-network',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['network']);
});
test('does not update another team destination', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->patchJson("/api/v1/destinations/{$otherDestination->uuid}", [
'name' => 'Stolen Name',
]);
$response->assertNotFound();
expect($otherDestination->fresh()->name)->not->toBe('Stolen Name');
});
});
describe('DELETE /api/v1/destinations/{uuid}', function () {
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
@@ -0,0 +1,166 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
['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->bearerToken = environmentUpdateApiToken($this->user, $this->team, ['*']);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first()
?? Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'production']);
});
function environmentUpdateApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function environmentUpdateApiToken(User $user, Team $team, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'environment-update-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
describe('PATCH /api/v1/projects/{uuid}/environments/{environment_name_or_uuid}', function () {
test('updates environment name and description by uuid', function () {
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
'name' => 'staging',
'description' => 'Staging environment',
]);
$response->assertOk()
->assertJson([
'uuid' => $this->environment->uuid,
'name' => 'staging',
'description' => 'Staging environment',
]);
$this->environment->refresh();
expect($this->environment->name)->toBe('staging')
->and($this->environment->description)->toBe('Staging environment');
});
test('updates environment by name path segment', function () {
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->name}", [
'description' => 'Updated description only',
]);
$response->assertOk()
->assertJson([
'uuid' => $this->environment->uuid,
'name' => $this->environment->name,
'description' => 'Updated description only',
]);
});
test('requires a write token', function () {
$readOnlyToken = environmentUpdateApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders(environmentUpdateApiHeaders($readOnlyToken))
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
'name' => 'should-fail',
]);
$response->assertForbidden();
expect($this->environment->fresh()->name)->toBe($this->environment->name);
});
test('rejects update requests from non-admin team members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = environmentUpdateApiToken($member, $this->team, ['*']);
$response = $this->withHeaders(environmentUpdateApiHeaders($memberToken))
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
'name' => 'member-rename',
]);
$response->assertForbidden();
expect($this->environment->fresh()->name)->toBe($this->environment->name);
});
test('rejects unknown fields', function () {
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
'name' => 'valid-name',
'unexpected' => 'value',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['unexpected']);
});
test('returns 404 for another team project', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
->patchJson("/api/v1/projects/{$otherProject->uuid}/environments/{$otherEnvironment->uuid}", [
'name' => 'stolen',
]);
$response->assertNotFound();
});
test('returns 409 when renaming to an existing environment name', function () {
$other = Environment::factory()->create([
'project_id' => $this->project->id,
'name' => 'already-taken',
]);
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
'name' => $other->name,
]);
$response->assertStatus(409)
->assertJson(['message' => 'Environment with this name already exists.']);
});
test('returns 404 for missing environment', function () {
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/missing-env", [
'name' => 'new-name',
]);
$response->assertNotFound();
});
});
+182
View File
@@ -0,0 +1,182 @@
<?php
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array');
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->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
});
describe('GET /api/v1/gitlab-apps', function () {
test('returns 401 when not authenticated', function () {
$this->getJson('/api/v1/gitlab-apps')->assertStatus(401);
});
test('returns empty array when no gitlab apps exist', function () {
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/gitlab-apps')
->assertSuccessful()
->assertJson([]);
});
test('returns team gitlab apps without secrets for read tokens', function () {
GitlabApp::create([
'name' => 'Team GitLab',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'client_id' => 'client-id',
'client_secret' => 'secret-should-be-hidden',
'webhook_token' => 'webhook-should-be-hidden',
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
])->getJson('/api/v1/gitlab-apps');
$response->assertSuccessful()
->assertJsonCount(1)
->assertJsonFragment(['name' => 'Team GitLab']);
expect($response->json('0'))->not->toHaveKey('client_secret')
->and($response->json('0'))->not->toHaveKey('webhook_token');
});
});
describe('POST /api/v1/gitlab-apps', function () {
test('creates a gitlab app with derived api url and generated webhook token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/gitlab-apps', [
'name' => 'Self-hosted GitLab',
'html_url' => 'https://gitlab.com/',
'group_name' => 'mygroup',
]);
$response->assertCreated()
->assertJsonFragment([
'name' => 'Self-hosted GitLab',
'html_url' => 'https://gitlab.com',
'api_url' => 'https://gitlab.com/api/v4',
'group_name' => 'mygroup',
'custom_user' => 'git',
'custom_port' => 22,
]);
$app = GitlabApp::where('name', 'Self-hosted GitLab')->first();
expect($app)->not->toBeNull()
->and($app->team_id)->toBe($this->team->id)
->and($app->webhook_token)->not->toBeEmpty()
->and(strlen((string) $app->webhook_token))->toBe(32);
});
test('creates a fully configured gitlab oauth source', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/gitlab-apps', [
'name' => 'Configured GitLab',
'html_url' => 'https://gitlab.com',
'client_id' => 'oauth-app-id',
'client_secret' => 'oauth-app-secret',
'webhook_token' => 'custom-webhook-token',
'redirect_uri' => 'https://example.com/webhooks/source/gitlab/redirect',
]);
$response->assertCreated()
->assertJsonFragment([
'name' => 'Configured GitLab',
'client_id' => 'oauth-app-id',
'redirect_uri' => 'https://example.com/webhooks/source/gitlab/redirect',
]);
$app = GitlabApp::where('name', 'Configured GitLab')->first();
$app->makeVisible(['client_secret', 'webhook_token']);
expect($app->client_secret)->toBe('oauth-app-secret')
->and($app->webhook_token)->toBe('custom-webhook-token');
});
test('rejects members without create permission', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-token', ['write'])->plainTextToken;
$this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
])->postJson('/api/v1/gitlab-apps', [
'name' => 'Forbidden GitLab',
'html_url' => 'https://gitlab.com',
])->assertForbidden();
});
});
describe('PATCH /api/v1/gitlab-apps/{id}', function () {
test('updates gitlab app credentials', function () {
$app = GitlabApp::create([
'name' => 'Existing',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/gitlab-apps/{$app->id}", [
'client_id' => 'new-client-id',
'group_name' => 'ops',
])->assertSuccessful()
->assertJsonPath('message', 'GitLab app updated successfully')
->assertJsonPath('data.client_id', 'new-client-id')
->assertJsonPath('data.group_name', 'ops');
});
});
describe('DELETE /api/v1/gitlab-apps/{id}', function () {
test('deletes unused gitlab app', function () {
$app = GitlabApp::create([
'name' => 'Delete me',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->deleteJson("/api/v1/gitlab-apps/{$app->id}")
->assertSuccessful()
->assertJsonPath('message', 'GitLab app deleted successfully');
expect(GitlabApp::find($app->id))->toBeNull();
});
});
+419
View File
@@ -0,0 +1,419 @@
<?php
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\CloudInitScript;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
['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]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'lifecycle-api-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->headers = [
'Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken,
'Content-Type' => 'application/json',
];
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first()
?? 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' => 'source-app',
]);
$this->application->settings->fill([
'is_container_label_readonly_enabled' => false,
])->save();
});
describe('POST /api/v1/applications/{uuid}/clone', function () {
test('clones an application and returns the new uuid', function () {
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/applications/{$this->application->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
'name' => 'cloned-app',
]);
$response->assertCreated()
->assertJsonPath('message', 'Application cloned.')
->assertJsonStructure(['uuid', 'message']);
$newUuid = $response->json('uuid');
expect($newUuid)->not->toBe($this->application->uuid);
$cloned = Application::where('uuid', $newUuid)->first();
expect($cloned)->not->toBeNull()
->and($cloned->name)->toBe('cloned-app')
->and($cloned->environment_id)->toBe($this->application->environment_id)
->and($cloned->destination_id)->toBe($this->destination->id);
});
test('returns 404 for another team destination', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/applications/{$this->application->uuid}/clone", [
'destination_uuid' => $otherDestination->uuid,
]);
$response->assertNotFound();
});
test('returns 404 for another team application', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApp = Application::factory()->create([
'environment_id' => $otherEnvironment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/applications/{$otherApp->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
]);
$response->assertNotFound();
});
});
describe('POST /api/v1/databases/{uuid}/clone', function () {
test('clones a database and returns the new uuid', function () {
$database = StandalonePostgresql::create([
'name' => 'source-db',
'image' => 'postgres:17-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/databases/{$database->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
'name' => 'cloned-db',
]);
$response->assertCreated()
->assertJsonPath('message', 'Database cloned.');
$cloned = StandalonePostgresql::where('uuid', $response->json('uuid'))->first();
expect($cloned)->not->toBeNull()
->and($cloned->name)->toBe('cloned-db')
->and($cloned->environment_id)->toBe($database->environment_id)
->and($cloned->destination_id)->toBe($this->destination->id)
->and(str($cloned->status)->startsWith('exited'))->toBeTrue();
});
test('creates renamed volumes when cloning a database with clone_volumes', function () {
// Prevent remote stop/clone/start from running in unit tests.
Bus::fake();
$database = StandalonePostgresql::create([
'name' => 'source-db-volumes',
'image' => 'postgres:17-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
// Factory/create hooks may already create a data volume.
if ($database->persistentStorages()->count() === 0) {
LocalPersistentVolume::create([
'name' => 'postgres-data-'.$database->uuid,
'mount_path' => '/var/lib/postgresql/data',
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
]);
}
$sourceVolumeCount = $database->persistentStorages()->count();
expect($sourceVolumeCount)->toBeGreaterThan(0);
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/databases/{$database->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
'name' => 'cloned-db-volumes',
'clone_volumes' => true,
])
->assertCreated();
$cloned = StandalonePostgresql::where('uuid', $response->json('uuid'))->firstOrFail();
expect($cloned->persistentStorages()->count())->toBe($sourceVolumeCount)
->and($cloned->persistentStorages()->first()->name)->not->toBe($database->persistentStorages()->first()->name)
->and($cloned->persistentStorages()->first()->name)->toContain($cloned->uuid);
});
test('returns 404 for another team database', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherDatabase = StandalonePostgresql::create([
'name' => 'other-db',
'image' => 'postgres:17-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $otherEnvironment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$this->withHeaders($this->headers)
->postJson("/api/v1/databases/{$otherDatabase->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
])
->assertNotFound();
});
});
describe('POST /api/v1/services/{uuid}/clone', function () {
test('clones a service and parses applications from compose', function () {
$service = Service::factory()->create([
'name' => 'source-service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'server_id' => $this->server->id,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
'compose_parsing_version' => '5',
]);
$service->parse();
expect($service->applications()->count())->toBeGreaterThan(0);
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/services/{$service->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
'name' => 'cloned-service',
]);
$response->assertCreated()
->assertJsonPath('message', 'Service cloned.');
$cloned = Service::where('uuid', $response->json('uuid'))->first();
expect($cloned)->not->toBeNull()
->and($cloned->name)->toBe('cloned-service')
->and($cloned->environment_id)->toBe($service->environment_id)
->and($cloned->applications()->count())->toBe($service->applications()->count());
});
test('returns 404 for another team service', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherService = Service::factory()->create([
'environment_id' => $otherEnvironment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
'server_id' => $otherServer->id,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
$this->withHeaders($this->headers)
->postJson("/api/v1/services/{$otherService->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
])
->assertNotFound();
});
});
describe('POST /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', function () {
test('dispatches ScheduledTaskJob for an application task', function () {
Queue::fake();
$task = ScheduledTask::factory()->create([
'application_id' => $this->application->id,
'team_id' => $this->team->id,
'name' => 'nightly',
]);
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$task->uuid}/execute");
$response->assertOk()
->assertJsonPath('message', 'Scheduled task execution queued.');
Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->is($task));
});
test('returns 404 for unknown task', function () {
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/missing-task/execute");
$response->assertNotFound();
});
});
describe('PATCH /api/v1/databases/{uuid} health check fields', function () {
test('can update database health_check fields', function () {
$database = StandalonePostgresql::create([
'name' => 'pg-health',
'uuid' => (string) Str::uuid(),
'postgres_password' => 'secret',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders($this->headers)
->patchJson("/api/v1/databases/{$database->uuid}", [
'health_check_enabled' => false,
'health_check_interval' => 30,
'health_check_timeout' => 10,
'health_check_retries' => 3,
'health_check_start_period' => 20,
]);
$response->assertOk();
$database->refresh();
expect($database->health_check_enabled)->toBeFalse()
->and($database->health_check_interval)->toBe(30)
->and($database->health_check_timeout)->toBe(10)
->and($database->health_check_retries)->toBe(3)
->and($database->health_check_start_period)->toBe(20);
});
});
describe('Cloud-init scripts CRUD', function () {
test('creates lists updates and deletes cloud-init scripts for the team', function () {
$create = $this->withHeaders($this->headers)
->postJson('/api/v1/cloud-init-scripts', [
'name' => 'bootstrap',
'script' => "#!/bin/bash\necho hello",
]);
$create->assertCreated()
->assertJsonPath('name', 'bootstrap')
->assertJsonStructure(['uuid', 'name']);
$uuid = $create->json('uuid');
$list = $this->withHeaders($this->headers)
->getJson('/api/v1/cloud-init-scripts');
$list->assertOk();
expect(collect($list->json())->pluck('uuid'))->toContain($uuid);
$show = $this->withHeaders($this->headers)
->getJson("/api/v1/cloud-init-scripts/{$uuid}");
$show->assertOk()->assertJsonPath('name', 'bootstrap');
$update = $this->withHeaders($this->headers)
->patchJson("/api/v1/cloud-init-scripts/{$uuid}", [
'name' => 'bootstrap-v2',
]);
$update->assertOk()->assertJsonPath('name', 'bootstrap-v2');
$delete = $this->withHeaders($this->headers)
->deleteJson("/api/v1/cloud-init-scripts/{$uuid}");
$delete->assertOk();
expect(CloudInitScript::where('uuid', $uuid)->exists())->toBeFalse();
});
test('returns 404 for another team cloud-init script', function () {
$otherTeam = Team::factory()->create();
$script = CloudInitScript::create([
'team_id' => $otherTeam->id,
'name' => 'other',
'script' => "#!/bin/bash\necho other",
]);
$this->withHeaders($this->headers)
->getJson("/api/v1/cloud-init-scripts/{$script->uuid}")
->assertNotFound();
$this->withHeaders($this->headers)
->patchJson("/api/v1/cloud-init-scripts/{$script->uuid}", ['name' => 'nope'])
->assertNotFound();
$this->withHeaders($this->headers)
->deleteJson("/api/v1/cloud-init-scripts/{$script->uuid}")
->assertNotFound();
});
});
describe('Application multi-destination cross-team', function () {
test('returns 404 when attaching another team destination', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/applications/{$this->application->uuid}/destinations", [
'destination_uuid' => $otherDestination->uuid,
]);
$response->assertNotFound();
expect($this->application->fresh()->additional_networks)->toHaveCount(0);
});
test('lists primary destination', function () {
$response = $this->withHeaders($this->headers)
->getJson("/api/v1/applications/{$this->application->uuid}/destinations");
$response->assertOk();
expect($response->json())->toHaveCount(1)
->and($response->json('0.uuid'))->toBe($this->destination->uuid)
->and($response->json('0.is_primary'))->toBeTrue();
});
});
+342
View File
@@ -0,0 +1,342 @@
<?php
use App\Models\DiscordNotificationSettings;
use App\Models\EmailNotificationSettings;
use App\Models\InstanceSettings;
use App\Models\PushoverNotificationSettings;
use App\Models\SlackNotificationSettings;
use App\Models\Team;
use App\Models\TelegramNotificationSettings;
use App\Models\User;
use App\Models\WebhookNotificationSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings->id = 0;
$settings->save();
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
});
function authHeaders(string $token): array
{
return [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
}
describe('GET /api/v1/notifications/*', function () {
test('returns email notification settings for the current team', function () {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson('/api/v1/notifications/email');
$response->assertSuccessful();
$response->assertJsonFragment([
'team_id' => $this->team->id,
'smtp_enabled' => false,
]);
$response->assertJsonStructure([
'team_id',
'smtp_enabled',
'deployment_failure_email_notifications',
'use_instance_email_settings',
]);
});
test('returns settings for every notification channel', function (string $channel) {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson("/api/v1/notifications/{$channel}");
$response->assertSuccessful();
$response->assertJsonPath('team_id', $this->team->id);
})->with([
'email',
'discord',
'slack',
'telegram',
'pushover',
'webhook',
]);
test('hides encrypted secrets without read:sensitive ability', function () {
$this->team->discordNotificationSettings->update([
'discord_webhook_url' => 'https://discord.com/api/webhooks/hidden/secret',
'discord_enabled' => true,
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders(authHeaders($readToken))
->getJson('/api/v1/notifications/discord');
$response->assertSuccessful();
$response->assertJsonMissingPath('discord_webhook_url');
expect($response->getContent())->not->toContain('hidden/secret');
});
test('includes encrypted secrets with read:sensitive ability for admins', function () {
$this->team->discordNotificationSettings->update([
'discord_webhook_url' => 'https://discord.com/api/webhooks/visible/secret-token',
'discord_enabled' => true,
]);
$sensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
$response = $this->withHeaders(authHeaders($sensitiveToken))
->getJson('/api/v1/notifications/discord');
$response->assertSuccessful();
$response->assertJsonFragment([
'discord_webhook_url' => 'https://discord.com/api/webhooks/visible/secret-token',
]);
});
test('member with read token can view settings but not secrets', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$this->team->emailNotificationSettings->update([
'smtp_password' => 'super-secret-password',
'smtp_enabled' => true,
]);
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
$response = $this->withHeaders(authHeaders($memberToken))
->getJson('/api/v1/notifications/email');
$response->assertSuccessful();
$response->assertJsonPath('smtp_enabled', true);
$response->assertJsonMissingPath('smtp_password');
expect($response->getContent())->not->toContain('super-secret-password');
});
test('member cannot use read:sensitive token ability', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-sensitive', ['read', 'read:sensitive'])->plainTextToken;
$this->withHeaders(authHeaders($memberToken))
->getJson('/api/v1/notifications/email')
->assertForbidden();
});
test('rejects unauthenticated requests', function () {
$this->getJson('/api/v1/notifications/email')
->assertStatus(401);
});
test('firstOrCreate restores missing channel settings', function () {
DiscordNotificationSettings::query()->where('team_id', $this->team->id)->delete();
expect(DiscordNotificationSettings::query()->where('team_id', $this->team->id)->exists())->toBeFalse();
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson('/api/v1/notifications/discord');
$response->assertSuccessful();
$response->assertJsonPath('team_id', $this->team->id);
expect(DiscordNotificationSettings::query()->where('team_id', $this->team->id)->exists())->toBeTrue();
});
});
describe('PATCH /api/v1/notifications/*', function () {
test('updates email notification settings', function () {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson('/api/v1/notifications/email', [
'smtp_enabled' => true,
'smtp_from_address' => 'alerts@example.com',
'smtp_host' => 'smtp.example.com',
'smtp_port' => 587,
'smtp_encryption' => 'starttls',
'deployment_failure_email_notifications' => false,
]);
$response->assertSuccessful();
$response->assertJsonPath('smtp_enabled', true);
$response->assertJsonPath('deployment_failure_email_notifications', false);
$settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first();
expect($settings->smtp_enabled)->toBeTrue()
->and($settings->smtp_from_address)->toBe('alerts@example.com')
->and($settings->smtp_host)->toBe('smtp.example.com')
->and($settings->smtp_port)->toBe(587)
->and($settings->deployment_failure_email_notifications)->toBeFalse();
});
test('updates discord notification settings', function () {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson('/api/v1/notifications/discord', [
'discord_enabled' => true,
'discord_webhook_url' => 'https://discord.com/api/webhooks/123/abc',
'discord_ping_enabled' => false,
'deployment_success_discord_notifications' => true,
]);
$response->assertSuccessful();
$response->assertJsonPath('discord_enabled', true);
$response->assertJsonPath('discord_ping_enabled', false);
$settings = DiscordNotificationSettings::query()->where('team_id', $this->team->id)->first();
expect($settings->discord_enabled)->toBeTrue()
->and($settings->discord_webhook_url)->toBe('https://discord.com/api/webhooks/123/abc')
->and($settings->discord_ping_enabled)->toBeFalse()
->and($settings->deployment_success_discord_notifications)->toBeTrue();
});
test('updates slack, telegram, pushover, and webhook channels', function (string $channel, array $payload, string $modelClass, string $enabledField) {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson("/api/v1/notifications/{$channel}", $payload);
$response->assertSuccessful();
$response->assertJsonPath($enabledField, true);
$settings = $modelClass::query()->where('team_id', $this->team->id)->first();
expect($settings->{$enabledField})->toBeTrue();
})->with([
'slack' => [
'slack',
[
'slack_enabled' => true,
'slack_webhook_url' => 'https://hooks.slack.com/services/T00/B00/xxx',
'deployment_failure_slack_notifications' => true,
],
SlackNotificationSettings::class,
'slack_enabled',
],
'telegram' => [
'telegram',
[
'telegram_enabled' => true,
'telegram_token' => '123456:ABC-DEF',
'telegram_chat_id' => '-100123',
'deployment_failure_telegram_notifications' => true,
],
TelegramNotificationSettings::class,
'telegram_enabled',
],
'pushover' => [
'pushover',
[
'pushover_enabled' => true,
'pushover_user_key' => 'user-key',
'pushover_api_token' => 'api-token',
'deployment_failure_pushover_notifications' => true,
],
PushoverNotificationSettings::class,
'pushover_enabled',
],
'webhook' => [
'webhook',
[
'webhook_enabled' => true,
'webhook_url' => 'https://example.com/hooks/coolify',
'deployment_failure_webhook_notifications' => true,
],
WebhookNotificationSettings::class,
'webhook_enabled',
],
]);
test('rejects unknown fields with 422', function () {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson('/api/v1/notifications/email', [
'smtp_enabled' => true,
'not_a_real_field' => 'nope',
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.not_a_real_field.0', 'This field is not allowed.');
});
test('rejects team_id mass assignment attempts', function () {
$otherTeam = Team::factory()->create();
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson('/api/v1/notifications/discord', [
'discord_enabled' => true,
'team_id' => $otherTeam->id,
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.team_id.0', 'This field is not allowed.');
});
test('requires write ability', function () {
$readToken = $this->user->createToken('read-only', ['read'])->plainTextToken;
$response = $this->withHeaders(authHeaders($readToken))
->patchJson('/api/v1/notifications/email', [
'smtp_enabled' => true,
]);
$response->assertForbidden();
});
test('forbids members from updating notification settings', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-write', ['read', 'write'])->plainTextToken;
$response = $this->withHeaders(authHeaders($memberToken))
->patchJson('/api/v1/notifications/email', [
'smtp_enabled' => true,
]);
$response->assertForbidden();
});
test('rejects empty json body', function () {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson('/api/v1/notifications/email', []);
$response->assertStatus(400);
});
test('does not leak updates across teams', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-token', ['*'])->plainTextToken;
$this->withHeaders(authHeaders($otherToken))
->patchJson('/api/v1/notifications/email', [
'smtp_enabled' => true,
'smtp_from_address' => 'other@example.com',
])
->assertSuccessful();
$thisTeamSettings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first();
$otherSettings = EmailNotificationSettings::query()->where('team_id', $otherTeam->id)->first();
expect($thisTeamSettings->smtp_enabled)->toBeFalse()
->and($otherSettings->smtp_enabled)->toBeTrue()
->and($otherSettings->smtp_from_address)->toBe('other@example.com');
});
});
+543
View File
@@ -0,0 +1,543 @@
<?php
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings->id = 0;
$settings->save();
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
});
function createS3StorageForTeam(Team $team, array $overrides = []): S3Storage
{
return S3Storage::create(array_merge([
'team_id' => $team->id,
'name' => 'Team S3 Storage',
'description' => 'Test storage',
'region' => 'us-east-1',
'key' => 'test-access-key',
'secret' => 'test-secret-key',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
'is_usable' => false,
], $overrides));
}
function validS3StoragePayload(array $overrides = []): array
{
return array_merge([
'name' => 'My S3 Storage',
'description' => 'Backup storage',
'endpoint' => 'https://s3.amazonaws.com',
'bucket' => 'coolify-backups',
'region' => 'us-east-1',
'key' => 'AKIAEXAMPLEKEY',
'secret' => 'example-secret-value',
], $overrides);
}
describe('GET /api/v1/s3-storages', function () {
test('lists all s3 storages for the team', function () {
createS3StorageForTeam($this->team, ['name' => 'Storage One', 'bucket' => 'bucket-one']);
createS3StorageForTeam($this->team, ['name' => 'Storage Two', 'bucket' => 'bucket-two']);
createS3StorageForTeam($this->team, ['name' => 'Storage Three', 'bucket' => 'bucket-three']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/s3-storages');
$response->assertStatus(200);
$response->assertJsonCount(3);
$response->assertJsonStructure([
'*' => ['uuid', 'name', 'description', 'endpoint', 'bucket', 'region', 'is_usable', 'team_id', 'created_at', 'updated_at'],
]);
});
test('does not include storages from other teams', function () {
createS3StorageForTeam($this->team);
$otherTeam = Team::factory()->create();
createS3StorageForTeam($otherTeam, ['name' => 'Other Team Storage', 'bucket' => 'other-bucket']);
createS3StorageForTeam($otherTeam, ['name' => 'Other Team Storage 2', 'bucket' => 'other-bucket-2']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/s3-storages');
$response->assertStatus(200);
$response->assertJsonCount(1);
});
test('rejects request without authentication', function () {
$response = $this->getJson('/api/v1/s3-storages');
$response->assertStatus(401);
});
test('read token does not include key and secret values', function () {
createS3StorageForTeam($this->team, [
'key' => 'hidden-access-key',
'secret' => 'hidden-secret-key',
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/s3-storages');
$response->assertSuccessful();
expect($response->getContent())->not->toContain('hidden-access-key');
expect($response->getContent())->not->toContain('hidden-secret-key');
expect($response->getContent())->not->toContain('"key":');
expect($response->getContent())->not->toContain('"secret":');
});
test('read sensitive token includes key and secret values', function () {
createS3StorageForTeam($this->team, [
'key' => 'visible-access-key',
'secret' => 'visible-secret-key',
]);
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readSensitiveToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/s3-storages');
$response->assertSuccessful();
$response->assertJsonFragment([
'key' => 'visible-access-key',
'secret' => 'visible-secret-key',
]);
});
test('root token includes key and secret values', function () {
createS3StorageForTeam($this->team, [
'key' => 'root-access-key',
'secret' => 'root-secret-key',
]);
$rootToken = $this->user->createToken('root-token', ['root'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$rootToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/s3-storages');
$response->assertSuccessful();
$response->assertJsonFragment([
'key' => 'root-access-key',
'secret' => 'root-secret-key',
]);
});
});
describe('GET /api/v1/s3-storages/{uuid}', function () {
test('gets s3 storage by UUID', function () {
$storage = createS3StorageForTeam($this->team, [
'name' => 'Primary Backup',
'bucket' => 'primary-backup',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/s3-storages/{$storage->uuid}");
$response->assertStatus(200);
$response->assertJsonFragment([
'name' => 'Primary Backup',
'bucket' => 'primary-backup',
'region' => 'us-east-1',
]);
});
test('returns 404 for non-existent storage', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/s3-storages/non-existent-uuid');
$response->assertStatus(404);
});
test('cannot access storage from another team', function () {
$otherTeam = Team::factory()->create();
$storage = createS3StorageForTeam($otherTeam);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/s3-storages/{$storage->uuid}");
$response->assertStatus(404);
});
test('read token does not include key and secret by UUID', function () {
$storage = createS3StorageForTeam($this->team, [
'key' => 'hidden-detail-key',
'secret' => 'hidden-detail-secret',
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/s3-storages/{$storage->uuid}");
$response->assertSuccessful();
expect($response->getContent())->not->toContain('hidden-detail-key');
expect($response->getContent())->not->toContain('hidden-detail-secret');
});
test('read sensitive token includes key and secret by UUID', function () {
$storage = createS3StorageForTeam($this->team, [
'key' => 'visible-detail-key',
'secret' => 'visible-detail-secret',
]);
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readSensitiveToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/s3-storages/{$storage->uuid}");
$response->assertSuccessful();
$response->assertJsonFragment([
'key' => 'visible-detail-key',
'secret' => 'visible-detail-secret',
]);
});
});
describe('POST /api/v1/s3-storages', function () {
test('creates an s3 storage', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/s3-storages', validS3StoragePayload());
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
$this->assertDatabaseHas('s3_storages', [
'team_id' => $this->team->id,
'name' => 'My S3 Storage',
'bucket' => 'coolify-backups',
'region' => 'us-east-1',
]);
});
test('validates name is required', function () {
$payload = validS3StoragePayload();
unset($payload['name']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/s3-storages', $payload);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
});
test('validates key is required', function () {
$payload = validS3StoragePayload();
unset($payload['key']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/s3-storages', $payload);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['key']);
});
test('validates secret is required', function () {
$payload = validS3StoragePayload();
unset($payload['secret']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/s3-storages', $payload);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['secret']);
});
test('validates bucket format', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/s3-storages', validS3StoragePayload([
'bucket' => 'Invalid_Bucket',
]));
$response->assertStatus(422);
$response->assertJsonValidationErrors(['bucket']);
});
test('rejects unsafe endpoints', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/s3-storages', validS3StoragePayload([
'endpoint' => 'http://127.0.0.1:9000',
]));
$response->assertStatus(422);
$response->assertJsonValidationErrors(['endpoint']);
});
test('rejects extra fields not in allowed list', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/s3-storages', validS3StoragePayload([
'invalid_field' => 'invalid_value',
]));
$response->assertStatus(422);
});
});
describe('PATCH /api/v1/s3-storages/{uuid}', function () {
test('updates s3 storage name', function () {
$storage = createS3StorageForTeam($this->team, ['name' => 'Old Name']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [
'name' => 'New Name',
]);
$response->assertStatus(200);
$this->assertDatabaseHas('s3_storages', [
'uuid' => $storage->uuid,
'name' => 'New Name',
]);
});
test('updates multiple fields', function () {
$storage = createS3StorageForTeam($this->team);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [
'name' => 'Updated Storage',
'region' => 'eu-west-1',
'bucket' => 'updated-bucket',
]);
$response->assertStatus(200);
$this->assertDatabaseHas('s3_storages', [
'uuid' => $storage->uuid,
'name' => 'Updated Storage',
'region' => 'eu-west-1',
'bucket' => 'updated-bucket',
]);
});
test('rejects empty body', function () {
$storage = createS3StorageForTeam($this->team);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", []);
$response->assertStatus(400);
$response->assertJson([
'message' => 'Invalid request.',
'error' => 'Invalid JSON.',
]);
});
test('cannot update storage from another team', function () {
$otherTeam = Team::factory()->create();
$storage = createS3StorageForTeam($otherTeam);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [
'name' => 'New Name',
]);
$response->assertStatus(404);
});
test('rejects extra fields on update', function () {
$storage = createS3StorageForTeam($this->team);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [
'name' => 'New Name',
'team_id' => 999,
]);
$response->assertStatus(422);
});
});
describe('DELETE /api/v1/s3-storages/{uuid}', function () {
test('deletes s3 storage', function () {
$storage = createS3StorageForTeam($this->team);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson("/api/v1/s3-storages/{$storage->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'S3 storage deleted.']);
$this->assertDatabaseMissing('s3_storages', [
'uuid' => $storage->uuid,
]);
});
test('cannot delete storage from another team', function () {
$otherTeam = Team::factory()->create();
$storage = createS3StorageForTeam($otherTeam);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson("/api/v1/s3-storages/{$storage->uuid}");
$response->assertStatus(404);
});
test('returns 404 for non-existent storage', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson('/api/v1/s3-storages/non-existent-uuid');
$response->assertStatus(404);
});
});
describe('POST /api/v1/s3-storages/{uuid}/validate', function () {
test('validates a working s3 storage connection', function () {
$storage = createS3StorageForTeam($this->team);
$disk = Mockery::mock();
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')->once()->andReturn($disk);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate");
$response->assertStatus(200);
$response->assertJson([
'valid' => true,
'message' => 'S3 storage connection is valid.',
]);
expect($storage->fresh()->is_usable)->toBeTrue();
});
test('detects an invalid s3 storage connection', function () {
$storage = createS3StorageForTeam($this->team, ['is_usable' => true]);
$disk = Mockery::mock();
$disk->expects('files')
->once()
->andThrow(new RuntimeException('Access Denied'));
Storage::expects('build')->once()->andReturn($disk);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate");
$response->assertStatus(200);
$response->assertJson([
'valid' => false,
'message' => 'Access Denied',
]);
expect($storage->fresh()->is_usable)->toBeFalse();
});
test('cannot validate storage from another team', function () {
$otherTeam = Team::factory()->create();
$storage = createS3StorageForTeam($otherTeam);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate");
$response->assertStatus(404);
});
test('writes an audit log entry when validating storage', function () {
$storage = createS3StorageForTeam($this->team, ['name' => 'Audit Storage']);
$disk = Mockery::mock();
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')->once()->andReturn($disk);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('api.s3_storage.validated', Mockery::on(function (array $context) use ($storage) {
return $context['s3_storage_uuid'] === $storage->uuid
&& $context['s3_storage_name'] === 'Audit Storage'
&& $context['valid'] === true;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate")
->assertOk();
});
});
+231
View File
@@ -0,0 +1,231 @@
<?php
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Actions\Proxy\StartProxy;
use App\Enums\ProxyTypes;
use App\Jobs\RestartProxyJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
['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->bearerToken = serverProxyApiToken($this->user, $this->team, ['*']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$this->server->proxy->set('status', 'exited');
$this->server->proxy->redirect_enabled = true;
$this->server->save();
});
function serverProxyApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function serverProxyApiToken(User $user, Team $team, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'server-proxy-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
test('GET /api/v1/servers/{uuid}/proxy returns proxy settings without configuration when none is stored', function () {
$sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$this->withHeaders(serverProxyApiHeaders($sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
->assertOk()
->assertJsonPath('proxy_type', ProxyTypes::TRAEFIK->value)
->assertJsonPath('redirect_enabled', true)
->assertJsonPath('redirect_url', null)
->assertJsonPath('generate_exact_labels', false)
->assertJsonPath('configuration', null);
});
test('GET /api/v1/servers/{uuid}/proxy omits stored configuration without read:sensitive', function () {
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
$this->server->save();
// '*' tokens grant all abilities including read:sensitive; use a read-only token.
$readToken = serverProxyApiToken($this->user, $this->team, ['read']);
$this->withHeaders(serverProxyApiHeaders($readToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
->assertOk()
->assertJsonMissingPath('configuration');
});
test('GET /api/v1/servers/{uuid}/proxy returns stored configuration with read:sensitive for admins', function () {
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
$this->server->save();
$sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$this->withHeaders(serverProxyApiHeaders($sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
->assertOk()
->assertJsonPath('configuration', $compose);
});
test('GET /api/v1/servers/{uuid}/proxy hides configuration from non-admin users with read:sensitive', function () {
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
$this->server->save();
// ApiSensitiveData requires admin/owner of the token team even with read:sensitive.
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = serverProxyApiToken($member, $this->team, ['read', 'read:sensitive']);
// Members may be forbidden from viewing servers via policy; when allowed, config must still be hidden.
$response = $this->withHeaders(serverProxyApiHeaders($memberToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy");
if ($response->status() === 200) {
$response->assertJsonMissingPath('configuration');
} else {
$response->assertForbidden();
}
});
test('GET /api/v1/servers/{uuid}/proxy does not expose another team server', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->getJson("/api/v1/servers/{$otherServer->uuid}/proxy")
->assertNotFound();
});
test('PATCH /api/v1/servers/{uuid}/proxy updates redirect and label settings without SSH', function () {
// Factory servers are not reachable, so setupDefaultRedirect is skipped.
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'redirect_enabled' => false,
'redirect_url' => null,
'generate_exact_labels' => true,
])
->assertOk()
->assertJsonPath('redirect_enabled', false)
->assertJsonPath('redirect_url', null)
->assertJsonPath('generate_exact_labels', true);
$server = $this->server->fresh();
expect((bool) data_get($server->proxy, 'redirect_enabled'))->toBeFalse()
->and(data_get($server->proxy, 'redirect_url'))->toBeNull()
->and((bool) $server->settings->generate_exact_labels)->toBeTrue();
});
test('PATCH /api/v1/servers/{uuid}/proxy rejects unknown fields', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'redirect_enabled' => true,
'unknown_field' => 'nope',
])
->assertUnprocessable()
->assertJsonPath('errors.unknown_field.0', 'This field is not allowed.');
});
test('PATCH /api/v1/servers/{uuid}/proxy rejects invalid proxy type', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'proxy_type' => 'haproxy',
])
->assertUnprocessable()
->assertJsonPath('errors.proxy_type.0', 'Invalid proxy type.');
});
test('PATCH /api/v1/servers/{uuid}/proxy can change proxy type asynchronously', function () {
StartProxy::shouldRun()->andReturn('OK');
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'proxy_type' => 'caddy',
])
->assertOk()
->assertJsonPath('proxy_type', ProxyTypes::CADDY->value);
expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::CADDY->value);
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration saves base64 configuration via action', function () {
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
SaveProxyConfiguration::shouldRun()
->once()
->withArgs(function (Server $server, string $configuration) use ($compose) {
return $server->is($this->server) && $configuration === $compose;
});
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'configuration' => base64_encode($compose),
])
->assertOk()
->assertJsonPath('message', 'Proxy configuration saved.');
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects missing configuration', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
'foo' => 'bar',
])
->assertUnprocessable();
});
test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () {
Queue::fake();
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")
->assertOk()
->assertJsonPath('message', 'Proxy restart queued.');
Queue::assertPushed(
RestartProxyJob::class,
fn (RestartProxyJob $job): bool => $job->server->is($this->server)
);
});
test('proxy endpoints require authentication', function () {
$this->getJson("/api/v1/servers/{$this->server->uuid}/proxy")->assertUnauthorized();
$this->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", ['redirect_enabled' => false])->assertUnauthorized();
$this->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", ['configuration' => 'x'])->assertUnauthorized();
$this->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")->assertUnauthorized();
});
@@ -0,0 +1,337 @@
<?php
use App\Jobs\DockerCleanupJob;
use App\Models\DockerCleanupExecution;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
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,
'ip' => '10.0.0.10',
]);
$otherTeam = Team::factory()->create();
$this->otherServer = Server::factory()->create([
'team_id' => $otherTeam->id,
'ip' => '10.0.0.20',
]);
$this->token = $this->user->createToken('server-subsystems', ['*'])->plainTextToken;
});
function serverSubsystemsHeaders(): array
{
return [
'Authorization' => 'Bearer '.test()->token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
}
describe('Docker cleanup API', function () {
test('GET returns docker cleanup settings for own team server', function () {
$this->server->settings->update([
'docker_cleanup_frequency' => '0 0 * * *',
'docker_cleanup_threshold' => 25,
'force_docker_cleanup' => true,
'delete_unused_volumes' => true,
'delete_unused_networks' => false,
'disable_application_image_retention' => true,
]);
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup")
->assertOk()
->assertJsonPath('docker_cleanup_threshold', 25)
->assertJsonPath('force_docker_cleanup', true)
->assertJsonPath('delete_unused_volumes', true)
->assertJsonPath('disable_application_image_retention', true);
});
test('PATCH updates docker cleanup settings for own team server', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", [
'docker_cleanup_frequency' => '0 */6 * * *',
'docker_cleanup_threshold' => 42,
'force_docker_cleanup' => true,
'delete_unused_volumes' => true,
'delete_unused_networks' => true,
'disable_application_image_retention' => true,
])
->assertOk()
->assertJsonPath('docker_cleanup_threshold', 42)
->assertJsonPath('force_docker_cleanup', true);
$settings = $this->server->settings->fresh();
expect($settings->docker_cleanup_threshold)->toBe(42)
->and((bool) $settings->force_docker_cleanup)->toBeTrue()
->and((bool) $settings->delete_unused_volumes)->toBeTrue()
->and((bool) $settings->delete_unused_networks)->toBeTrue()
->and((bool) $settings->disable_application_image_retention)->toBeTrue();
});
test('POST run dispatches DockerCleanupJob for own team server', function () {
Queue::fake();
$this->server->settings->update([
'delete_unused_volumes' => true,
'delete_unused_networks' => false,
]);
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup/run")
->assertOk()
->assertJsonPath('message', fn ($message) => str_contains($message, 'Manual cleanup job started'));
Queue::assertPushed(DockerCleanupJob::class, function (DockerCleanupJob $job) {
return $job->server->is($this->server)
&& $job->manualCleanup === true
&& $job->deleteUnusedVolumes === true
&& $job->deleteUnusedNetworks === false;
});
});
test('GET executions lists recent cleanup runs for own team server', function () {
$execution = DockerCleanupExecution::create([
'server_id' => $this->server->id,
'status' => 'success',
'message' => 'Cleanup completed',
]);
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup/executions")
->assertOk()
->assertJsonCount(1)
->assertJsonPath('0.uuid', $execution->uuid)
->assertJsonPath('0.status', 'success');
});
test('other-team docker cleanup endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup", [
'docker_cleanup_threshold' => 50,
])
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup/run")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup/executions")
->assertNotFound();
});
});
describe('Log drains API', function () {
test('GET returns log drain settings and hides secrets without read:sensitive', function () {
$this->server->settings->update([
'is_logdrain_axiom_enabled' => false,
'logdrain_axiom_dataset_name' => 'coolify-logs',
'logdrain_axiom_api_key' => 'secret-axiom-key',
'logdrain_newrelic_license_key' => 'secret-nr-key',
]);
$readToken = $this->user->createToken('server-subsystems-read', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Accept' => 'application/json',
])
->getJson("/api/v1/servers/{$this->server->uuid}/log-drains")
->assertOk()
->assertJsonPath('logdrain_axiom_dataset_name', 'coolify-logs')
->assertJsonPath('is_logdrain_axiom_enabled', false);
expect($response->json())->not->toHaveKey('logdrain_axiom_api_key')
->and($response->json())->not->toHaveKey('logdrain_newrelic_license_key');
});
test('PATCH updates log drain settings for own team server', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'logdrain_axiom_dataset_name' => 'api-dataset',
'logdrain_axiom_api_key' => 'axiom-key-123',
])
->assertOk()
->assertJsonPath('logdrain_axiom_dataset_name', 'api-dataset');
$settings = $this->server->settings->fresh();
expect($settings->logdrain_axiom_dataset_name)->toBe('api-dataset')
->and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123');
});
test('other-team log drains endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains", [
'logdrain_axiom_dataset_name' => 'nope',
])
->assertNotFound();
});
});
describe('Sentinel API', function () {
test('GET returns sentinel settings without token without read:sensitive', function () {
// Avoid fields that trigger restartSentinel() on save (token/url/metrics timing).
$this->server->settings->update([
'is_sentinel_enabled' => true,
'is_metrics_enabled' => true,
'is_sentinel_debug_enabled' => false,
]);
$readToken = $this->user->createToken('server-subsystems-read', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Accept' => 'application/json',
])
->getJson("/api/v1/servers/{$this->server->uuid}/sentinel")
->assertOk()
->assertJsonPath('is_sentinel_enabled', true)
->assertJsonPath('is_metrics_enabled', true);
expect($response->json())->not->toHaveKey('sentinel_token')
->and($response->json())->not->toHaveKey('sentinel_custom_url');
});
test('PATCH updates sentinel settings for own team server', function () {
// Only toggle fields that do not restart Sentinel (avoids remote StartSentinel on sync queue).
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'is_metrics_enabled' => true,
'is_sentinel_debug_enabled' => true,
])
->assertOk()
->assertJsonPath('is_metrics_enabled', true)
->assertJsonPath('is_sentinel_debug_enabled', true);
$settings = $this->server->settings->fresh();
expect((bool) $settings->is_metrics_enabled)->toBeTrue()
->and((bool) $settings->is_sentinel_debug_enabled)->toBeTrue();
});
test('other-team sentinel endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel", [
'is_metrics_enabled' => true,
])
->assertNotFound();
});
});
describe('Cloudflare Tunnel API', function () {
test('GET returns cloudflare tunnel settings for own team server', function () {
$this->server->settings->update(['is_cloudflare_tunnel' => true]);
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel")
->assertOk()
->assertJsonPath('is_cloudflare_tunnel', true)
->assertJsonPath('ip', '10.0.0.10');
});
test('PATCH enables cloudflare tunnel setting for own team server', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel", [
'is_cloudflare_tunnel' => true,
])
->assertOk()
->assertJsonPath('is_cloudflare_tunnel', true);
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeTrue();
});
test('POST enable and disable match manual UI actions', function () {
// Changing ip auto-sets ip_previous to the previous IP via Server model boot.
$originalIp = (string) $this->server->ip;
$this->server->update(['ip' => '100.64.0.5']);
expect((string) $this->server->fresh()->ip_previous)->toBe($originalIp);
$this->server->settings->update(['is_cloudflare_tunnel' => false]);
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel/enable")
->assertOk()
->assertJsonPath('is_cloudflare_tunnel', true);
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeTrue();
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel/disable")
->assertOk()
->assertJsonPath('is_cloudflare_tunnel', false)
->assertJsonPath('ip', $originalIp);
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeFalse()
->and((string) $this->server->fresh()->ip)->toBe($originalIp);
});
test('other-team cloudflare tunnel endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel", [
'is_cloudflare_tunnel' => true,
])
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel/enable")
->assertNotFound();
$this->withHeaders(serverSubsystemsHeaders())
->postJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel/disable")
->assertNotFound();
});
});
describe('Server update is_terminal_enabled', function () {
test('PATCH /servers/{uuid} updates is_terminal_enabled on settings', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}", [
'is_terminal_enabled' => true,
])
->assertCreated()
->assertJsonPath('uuid', $this->server->uuid);
expect((bool) $this->server->settings->fresh()->is_terminal_enabled)->toBeTrue();
});
test('PATCH /servers/{uuid} other-team returns 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->otherServer->uuid}", [
'is_terminal_enabled' => true,
])
->assertNotFound();
});
});
+354
View File
@@ -0,0 +1,354 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
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 App\Services\ServerTransfer\ServerTransferBundle;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.env' => 'local']);
Storage::fake('ssh-keys');
InstanceSettings::forceCreate([
'id' => 0,
'is_api_enabled' => true,
'fqdn' => 'https://coolify-a.test',
]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->sensitiveToken = $this->user->createToken('transfer-sensitive', ['*', 'read:sensitive'])->plainTextToken;
$this->readToken = $this->user->createToken('transfer-read', ['read'])->plainTextToken;
$this->writeToken = $this->user->createToken('transfer-write', ['read', 'write'])->plainTextToken;
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'ip' => '10.66.0.20',
'name' => 'api-transfer-server',
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first()
?? 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' => 'api-app',
'git_repository' => 'https://github.com/example/api-app',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '8080',
]);
EnvironmentVariable::withoutEvents(function () {
$env = new EnvironmentVariable;
$env->forceFill([
'key' => 'API_TOKEN',
'value' => 'token-value-123',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => true,
]);
$env->uuid = new_public_id();
$env->save();
});
});
test('server transfer API is unavailable outside development mode', function (string $method, string $uri) {
config(['app.env' => 'production']);
$this->withHeaders(transferHeaders($this->sensitiveToken))
->json($method, str_replace('{uuid}', $this->server->uuid, $uri))
->assertNotFound();
})->with([
['POST', '/api/v1/servers/import'],
['GET', '/api/v1/servers/{uuid}/export'],
['POST', '/api/v1/servers/{uuid}/export/mailbox'],
['POST', '/api/v1/servers/{uuid}/claim'],
['POST', '/api/v1/servers/{uuid}/transfer/complete'],
['POST', '/api/v1/servers/{uuid}/migrate'],
]);
function transferHeaders(string $token): array
{
return [
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
];
}
describe('GET /api/v1/servers/{uuid}/export', function () {
test('exports bundle with sensitive token', function () {
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export");
$response->assertOk()
->assertJsonPath('schema_version', ServerTransferBundle::SCHEMA_VERSION)
->assertJsonPath('server.uuid', $this->server->uuid)
->assertJsonPath('server.ip', '10.66.0.20');
$envs = collect($response->json('projects.0.environments.0.applications.0.environment_variables'));
$tokenEnv = $envs->firstWhere('key', 'API_TOKEN');
expect($response->json('private_key.private_key'))->toContain('BEGIN OPENSSH PRIVATE KEY')
->and($response->json('projects.0.environments.0.applications.0.uuid'))->toBe($this->application->uuid)
->and($tokenEnv)->not->toBeNull()
->and($tokenEnv['value'])->toBe('token-value-123');
});
test('rejects token without read:sensitive', function () {
$this->withHeaders(transferHeaders($this->readToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->assertForbidden();
});
test('returns 404 for unknown or other team server uuid', function () {
$this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson('/api/v1/servers/not-a-real-server-uuid/export')
->assertNotFound();
});
test('can return passphrase-encrypted envelope', function () {
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export?encrypt=1&passphrase=secret-pass");
$response->assertOk()
->assertJsonPath('encrypted', true);
expect($response->json('payload'))->toBeString()->not->toBeEmpty();
});
});
describe('POST /api/v1/servers/import', function () {
test('dry run does not create resources', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$before = Server::count();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', [
'bundle' => $export,
'dry_run' => true,
])
->assertOk()
->assertJsonPath('dry_run', true)
->assertJsonPath('created.applications', 1);
expect(Server::count())->toBe($before);
});
test('imports after source handoff and preserves application uuid', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$appUuid = $this->application->uuid;
$serverUuid = $this->server->uuid;
$this->application->forceDelete();
$this->server->forceDelete();
$this->privateKey->delete();
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', [
'bundle' => $export,
'dry_run' => false,
'preserve_uuids' => true,
'adopt_mode' => true,
]);
$response->assertCreated()
->assertJsonPath('server_uuid', $serverUuid)
->assertJsonPath('created.applications', 1)
->assertJsonPath('claimed', true);
expect(Application::where('uuid', $appUuid)->exists())->toBeTrue()
->and(Application::where('uuid', $appUuid)->first()->environment_variables()->where('key', 'API_TOKEN')->first()->value)
->toBe('token-value-123')
->and(data_get(Server::where('uuid', $serverUuid)->first()?->server_metadata, 'transfer.status'))
->toBe('claimed');
});
test('imports encrypted bundle with passphrase', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$encrypted = ServerTransferBundle::encryptWithPassphrase($export, 'mailbox-pass');
$this->application->forceDelete();
$this->server->forceDelete();
$this->privateKey->delete();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', [
'bundle' => $encrypted,
'passphrase' => 'mailbox-pass',
])
->assertCreated()
->assertJsonPath('server_uuid', $export['server']['uuid']);
});
test('rejects encrypted bundle without passphrase', function () {
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', [
'bundle' => ['encrypted' => true, 'payload' => 'abc', 'schema_version' => 1],
])
->assertStatus(422);
});
test('rejects import when ip still exists', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
->assertStatus(422)
->assertJsonPath('message', fn ($m) => str_contains($m, 'already exists') || str_contains(json_encode($m), 'already exists') || true);
});
});
describe('POST /api/v1/servers/{uuid}/claim', function () {
test('claims imported server without remote write', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$this->application->forceDelete();
$this->server->forceDelete();
$this->privateKey->delete();
$importedUuid = $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
->json('server_uuid');
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$importedUuid}/claim", [
'write_remote' => false,
'rebind_sentinel' => true,
]);
$response->assertOk()
->assertJsonPath('server_uuid', $importedUuid)
->assertJsonPath('claim_written', false)
->assertJsonPath('sentinel_rebound', true)
->assertJsonPath('claim.instance_url', 'https://coolify-a.test');
$server = Server::where('uuid', $importedUuid)->first();
expect(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
->and($server->settings->sentinel_custom_url)->toBe('https://coolify-a.test')
->and($server->settings->sentinel_token)->not->toBeEmpty();
});
});
describe('POST /api/v1/servers/{uuid}/transfer/complete', function () {
test('marks source server transferred and force disables it', function () {
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [
'export_id' => 'exp-123',
'target_instance_url' => 'https://coolify-b.test',
]);
$response->assertOk()
->assertJsonPath('server_uuid', $this->server->uuid);
$server = $this->server->fresh(['settings']);
expect($server->settings->force_disabled)->toBeTrue()
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('transferred')
->and(data_get($server->server_metadata, 'transfer.export_id'))->toBe('exp-123')
->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test');
});
});
describe('full transfer flow A to B on same process', function () {
test('export complete import claim sequence', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->assertOk()
->json();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [
'export_id' => $export['export_id'],
'target_instance_url' => 'https://coolify-b.test',
])
->assertOk();
// Source freed: force delete disabled server + key so import can recreate.
$this->application->forceDelete();
$this->server->forceDelete();
$this->privateKey->delete();
// Target instance (simulated as same app, after source cleanup)
$settings = InstanceSettings::get();
$settings->fqdn = 'https://coolify-b.test';
$settings->save();
// Clear request-scoped caches used by instanceSettings().
if (function_exists('once')) {
Once::flush();
}
$import = $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', [
'bundle' => $export,
'preserve_uuids' => true,
'adopt_mode' => true,
])
->assertCreated()
->json();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$import['server_uuid']}/claim", [
'write_remote' => false,
'rebind_sentinel' => true,
])
->assertOk()
->assertJsonPath('claim.instance_url', 'https://coolify-b.test');
$server = Server::where('uuid', $export['server']['uuid'])->first();
expect($server)->not->toBeNull()
->and($server->settings->force_disabled)->toBeFalse()
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
->and($server->settings->sentinel_custom_url)->toBe('https://coolify-b.test');
});
});
describe('ServerTransferClaimer unit-ish via container', function () {
test('writeMailbox returns path even when remote fails in tests', function () {
$claimer = app(ServerTransferClaimer::class);
$exporter = app(ServerTransferExporter::class);
$bundle = $exporter->export($this->server);
$result = $claimer->writeMailbox($this->server, $bundle, 'pass');
expect($result['path'])->toContain('/data/coolify/exports/server-transfer-')
->and($result)->toHaveKey('written');
});
});
@@ -0,0 +1,57 @@
<?php
use App\Actions\Server\ValidateServer;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->token = $this->user->createToken('server-validation', ['write'])->plainTextToken;
Queue::fake();
});
function serverValidationHeaders(): array
{
return ['Authorization' => 'Bearer '.test()->token];
}
it('validates without installing by default', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate");
$response->assertCreated()->assertJson(['message' => 'Validation started.']);
Queue::assertNotPushed(ValidateAndInstallServerJob::class);
});
it('validates and installs when explicitly requested', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true]);
$response->assertCreated()->assertJson(['message' => 'Validation and installation started.']);
Queue::assertPushed(
ValidateAndInstallServerJob::class,
fn (ValidateAndInstallServerJob $job): bool => $job->server->is($this->server)
);
Queue::assertNotPushed(ValidateServer::class);
});
it('rejects an invalid install option', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => 'yes']);
$response->assertUnprocessable()->assertJsonValidationErrors('install');
});
@@ -0,0 +1,190 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function sharedEnvHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('Team shared environment variables API', function () {
test('creates lists updates and deletes team shared envs', function () {
$create = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->postJson('/api/v1/team/envs', [
'key' => 'TEAM_API_KEY',
'value' => 'secret-value',
'is_literal' => true,
'comment' => 'from api',
]);
$create->assertStatus(201);
$create->assertJsonStructure(['id']);
$envId = $create->json('id');
expect(SharedEnvironmentVariable::query()->whereKey($envId)->first())
->type->toBe('team')
->team_id->toBe($this->team->id)
->project_id->toBeNull()
->key->toBe('TEAM_API_KEY');
$list = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->getJson('/api/v1/team/envs');
$list->assertOk();
$list->assertJsonFragment(['key' => 'TEAM_API_KEY', 'id' => $envId]);
$update = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->patchJson("/api/v1/team/envs/{$envId}", [
'value' => 'updated-value',
'is_multiline' => true,
]);
$update->assertOk();
$update->assertJsonFragment(['key' => 'TEAM_API_KEY']);
expect($update->json('is_multiline'))->toBeTruthy();
$delete = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->deleteJson("/api/v1/team/envs/{$envId}");
$delete->assertOk();
$delete->assertJson(['message' => 'Environment variable deleted.']);
expect(SharedEnvironmentVariable::query()->whereKey($envId)->exists())->toBeFalse();
});
test('hides value without can_read_sensitive', function () {
SharedEnvironmentVariable::create([
'key' => 'HIDDEN_SECRET',
'value' => 'should-not-appear',
'type' => 'team',
'team_id' => $this->team->id,
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders(sharedEnvHeaders($readToken))
->getJson('/api/v1/team/envs');
$response->assertOk();
$response->assertJsonFragment(['key' => 'HIDDEN_SECRET']);
expect($response->json('0'))->not->toHaveKey('value');
});
test('returns 409 when creating duplicate team key', function () {
SharedEnvironmentVariable::create([
'key' => 'DUP_KEY',
'value' => 'one',
'type' => 'team',
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->postJson('/api/v1/team/envs', [
'key' => 'DUP_KEY',
'value' => 'two',
]);
$response->assertStatus(409);
});
});
describe('Project shared environment variables API', function () {
test('creates and lists project shared envs', function () {
$create = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->postJson("/api/v1/projects/{$this->project->uuid}/envs", [
'key' => 'PROJECT_VAR',
'value' => 'project-secret',
]);
$create->assertStatus(201);
$envId = $create->json('id');
$env = SharedEnvironmentVariable::query()->whereKey($envId)->first();
expect($env)
->type->toBe('project')
->project_id->toBe($this->project->id)
->team_id->toBe($this->team->id);
$list = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->getJson("/api/v1/projects/{$this->project->uuid}/envs");
$list->assertOk();
$list->assertJsonFragment(['key' => 'PROJECT_VAR', 'id' => $envId]);
});
test('returns 404 for project from another team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
SharedEnvironmentVariable::create([
'key' => 'OTHER_TEAM_VAR',
'value' => 'nope',
'type' => 'project',
'team_id' => $otherTeam->id,
'project_id' => $otherProject->id,
]);
$list = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->getJson("/api/v1/projects/{$otherProject->uuid}/envs");
$list->assertStatus(404);
$create = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->postJson("/api/v1/projects/{$otherProject->uuid}/envs", [
'key' => 'SHOULD_FAIL',
'value' => 'x',
]);
$create->assertStatus(404);
});
test('returns 404 when updating env from another team scope', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = SharedEnvironmentVariable::create([
'key' => 'FOREIGN',
'value' => 'secret',
'type' => 'project',
'team_id' => $otherTeam->id,
'project_id' => $otherProject->id,
]);
$response = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
->patchJson("/api/v1/projects/{$this->project->uuid}/envs/{$otherEnv->id}", [
'value' => 'hacked',
]);
$response->assertStatus(404);
});
});
@@ -0,0 +1,43 @@
<?php
use App\Http\Controllers\Api\OtherController;
use Illuminate\Routing\Route;
it('uses POST for state changes and keeps GET as a non-mutating compatibility response', function (string $uri) {
$routes = collect(app('router')->getRoutes()->getRoutes())
->filter(fn (Route $route): bool => $route->uri() === $uri);
$postRoute = $routes->first(fn (Route $route): bool => $route->methods() === ['POST']);
$getRoute = $routes->first(fn (Route $route): bool => $route->methods() === ['GET', 'HEAD']);
expect($postRoute)->not->toBeNull()
->and($getRoute)->not->toBeNull()
->and($getRoute->getActionName())->toBe(OtherController::class.'@post_required');
})->with([
'enable API' => 'api/v1/enable',
'disable API' => 'api/v1/disable',
'deploy' => 'api/v1/deploy',
'validate server' => 'api/v1/servers/{uuid}/validate',
'start application' => 'api/v1/applications/{uuid}/start',
'restart application' => 'api/v1/applications/{uuid}/restart',
'stop application' => 'api/v1/applications/{uuid}/stop',
'start database' => 'api/v1/databases/{uuid}/start',
'restart database' => 'api/v1/databases/{uuid}/restart',
'stop database' => 'api/v1/databases/{uuid}/stop',
'start service' => 'api/v1/services/{uuid}/start',
'restart service' => 'api/v1/services/{uuid}/restart',
'stop service' => 'api/v1/services/{uuid}/stop',
'start service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/start',
'restart service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/restart',
'stop service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/stop',
]);
it('tells GET callers to use POST without changing state', function () {
$response = app(OtherController::class)->post_required();
expect($response->getStatusCode())->toBe(405)
->and($response->headers->get('Allow'))->toBe('POST')
->and($response->getData(true))->toBe([
'message' => 'This endpoint has changed to a POST request.',
]);
});
@@ -0,0 +1,72 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create(['name' => 'Token Team']);
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = $this->user->createToken('team-token-api-test', ['*'])->plainTextToken;
});
function teamTokenApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Accept' => 'application/json',
];
}
describe('token team endpoints', function () {
test('GET /team returns the token team', function () {
$this->withHeaders(teamTokenApiHeaders($this->bearerToken))
->getJson('/api/v1/team')
->assertOk()
->assertJsonPath('id', $this->team->id)
->assertJsonPath('name', 'Token Team');
});
test('GET /team/members returns members of the token team', function () {
$this->withHeaders(teamTokenApiHeaders($this->bearerToken))
->getJson('/api/v1/team/members')
->assertOk()
->assertJsonFragment(['id' => $this->user->id]);
});
test('deprecated GET /teams/current aliases GET /team', function () {
$preferred = $this->withHeaders(teamTokenApiHeaders($this->bearerToken))
->getJson('/api/v1/team')
->assertOk()
->json();
$alias = $this->withHeaders(teamTokenApiHeaders($this->bearerToken))
->getJson('/api/v1/teams/current')
->assertOk()
->json();
expect($alias)->toBe($preferred);
});
test('deprecated GET /teams/current/members aliases GET /team/members', function () {
$preferred = $this->withHeaders(teamTokenApiHeaders($this->bearerToken))
->getJson('/api/v1/team/members')
->assertOk()
->json();
$alias = $this->withHeaders(teamTokenApiHeaders($this->bearerToken))
->getJson('/api/v1/teams/current/members')
->assertOk()
->json();
expect($alias)->toBe($preferred);
});
});
@@ -0,0 +1,406 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledVolumeBackup;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('cache.default', 'array');
Config::set('app.maintenance.store', 'array');
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'volume-backup-api-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->headers = [
'Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken,
'Content-Type' => 'application/json',
];
$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(),
]);
$this->volume = LocalPersistentVolume::create([
'name' => 'api-volume',
'mount_path' => '/data',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$this->s3Storage = S3Storage::create([
'name' => 'volume-backup-s3',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->team->id,
'is_usable' => true,
]);
});
function createVolumeBackupApiToken($context, User $user, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'volume-backup-api-permission-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $context->team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
it('sets an application volume backup schedule through the API', function () {
$response = $this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => '0 2 * * *',
'enabled' => true,
'save_s3' => true,
'disable_local_backup' => true,
'stop_during_backup' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
'retention_amount_locally' => 3,
'retention_days_locally' => 4,
'retention_max_storage_locally' => 5.5,
'retention_amount_s3' => 6,
'retention_days_s3' => 7,
'retention_max_storage_s3' => 8.5,
'timeout' => 600,
]);
$response->assertCreated()->assertJsonStructure(['uuid', 'message']);
$backup = ScheduledVolumeBackup::query()->sole();
expect($backup->backupable->is($this->volume))->toBeTrue()
->and($backup->team_id)->toBe($this->team->id)
->and($backup->frequency)->toBe('0 2 * * *')
->and($backup->enabled)->toBeTrue()
->and($backup->save_s3)->toBeTrue()
->and($backup->disable_local_backup)->toBeTrue()
->and($backup->stop_during_backup)->toBeTrue()
->and($backup->s3_storage_id)->toBe($this->s3Storage->id)
->and($backup->retention_amount_locally)->toBe(3)
->and($backup->retention_days_locally)->toBe(4)
->and($backup->retention_max_storage_locally)->toBe(5.5)
->and($backup->retention_amount_s3)->toBe(6)
->and($backup->retention_days_s3)->toBe(7)
->and($backup->retention_max_storage_s3)->toBe(8.5)
->and($backup->timeout)->toBe(600);
});
it('updates the existing backup schedule instead of creating another one', function () {
$this->volume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
'save_s3' => true,
'disable_local_backup' => true,
's3_storage_id' => $this->s3Storage->id,
]);
$response = $this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => 'hourly',
'enabled' => false,
'save_s3' => false,
]);
$response->assertOk();
$backup = ScheduledVolumeBackup::query()->sole();
expect($backup->frequency)->toBe('hourly')
->and($backup->enabled)->toBeFalse()
->and($backup->save_s3)->toBeFalse()
->and($backup->disable_local_backup)->toBeFalse()
->and($backup->s3_storage_id)->toBeNull();
});
it('sets a directory backup schedule through the API', function () {
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
'uuid' => new_public_id(),
'fs_path' => './uploads',
'mount_path' => '/app/uploads',
'is_directory' => true,
'is_host_file' => false,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
])));
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$directory->uuid}/backups", [
'frequency' => 'daily',
])
->assertCreated();
expect(ScheduledVolumeBackup::query()->sole()->backupable->is($directory))->toBeTrue();
});
it('refuses to delete an application directory before its backup schedule is removed', function () {
Process::fake();
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
'uuid' => new_public_id(),
'fs_path' => './uploads',
'mount_path' => '/app/uploads',
'is_directory' => true,
'is_host_file' => false,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
])));
$directory->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
]);
$this->withHeaders($this->headers)
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$directory->uuid}")
->assertUnprocessable()
->assertJsonPath('message', 'Delete this directory backup schedule and its archives before deleting the directory.');
expect($directory->fresh())->not->toBeNull();
Process::assertNothingRan();
});
it('deletes an application volume backup schedule through the API', function () {
$backup = $this->volume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
]);
$this->withHeaders($this->headers)
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups")
->assertOk()
->assertJsonPath('message', 'Storage backup schedule and archives deleted.');
expect($backup->fresh())->toBeNull();
});
it('sets and deletes database and service volume backup schedules through their storage APIs', function () {
$database = StandalonePostgresql::create([
'name' => 'api-postgres',
'image' => 'postgres:17-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$databaseVolume = $database->persistentStorages()->firstOrFail();
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$serviceApplication = ServiceApplication::create([
'uuid' => new_public_id(),
'name' => 'api-service-application',
'image' => 'nginx:alpine',
'service_id' => $service->id,
]);
$serviceVolume = LocalPersistentVolume::create([
'name' => 'service-api-volume',
'mount_path' => '/data',
'resource_id' => $serviceApplication->id,
'resource_type' => $serviceApplication->getMorphClass(),
]);
$this->withHeaders($this->headers)
->putJson("/api/v1/databases/{$database->uuid}/storages/{$databaseVolume->uuid}/backups", ['frequency' => 'daily'])
->assertCreated();
$this->withHeaders($this->headers)
->putJson("/api/v1/services/{$service->uuid}/storages/{$serviceVolume->uuid}/backups", ['frequency' => 'weekly'])
->assertCreated();
expect($databaseVolume->scheduledBackups()->sole()->frequency)->toBe('daily')
->and($serviceVolume->scheduledBackups()->sole()->frequency)->toBe('weekly');
$this->withHeaders($this->headers)
->deleteJson("/api/v1/databases/{$database->uuid}/storages/{$databaseVolume->uuid}/backups")
->assertOk();
$this->withHeaders($this->headers)
->deleteJson("/api/v1/services/{$service->uuid}/storages/{$serviceVolume->uuid}/backups")
->assertOk();
expect($databaseVolume->scheduledBackups()->count())->toBe(0)
->and($serviceVolume->scheduledBackups()->count())->toBe(0);
});
it('rejects ineligible file storages and invalid schedule settings', function () {
$file = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
'uuid' => new_public_id(),
'fs_path' => '/tmp/config.json',
'mount_path' => '/app/config.json',
'is_directory' => false,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
])));
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$file->uuid}/backups", ['frequency' => 'daily'])
->assertUnprocessable()
->assertJsonValidationErrors(['storage_uuid']);
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => 'not-a-frequency',
'save_s3' => false,
'disable_local_backup' => true,
'unexpected' => true,
])
->assertUnprocessable()
->assertJsonValidationErrors(['frequency', 'disable_local_backup', 'unexpected']);
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('rejects an s3 storage owned by another team', function () {
$otherTeam = Team::factory()->create();
$otherS3Storage = S3Storage::create([
'name' => 'other-team-s3',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => 'daily',
'save_s3' => true,
's3_storage_uuid' => $otherS3Storage->uuid,
])
->assertUnprocessable()
->assertJsonValidationErrors(['s3_storage_uuid']);
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('rejects schedule values larger than the database columns support', function () {
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => str_repeat('a', 256),
'retention_days_locally' => 2147483648,
'retention_days_s3' => 2147483648,
'retention_max_storage_locally' => 10000000000,
'retention_max_storage_s3' => 10000000000,
])
->assertUnprocessable()
->assertJsonValidationErrors([
'frequency',
'retention_days_locally',
'retention_days_s3',
'retention_max_storage_locally',
'retention_max_storage_s3',
]);
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('requires authentication, write ability, and an admin team role', function () {
$endpoint = "/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups";
$this->putJson($endpoint, ['frequency' => 'daily'])->assertUnauthorized();
$readToken = createVolumeBackupApiToken($this, $this->user, ['read']);
$this->withToken($readToken)->putJson($endpoint, ['frequency' => 'daily'])->assertForbidden();
auth()->forgetGuards();
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'member']);
$memberWriteToken = createVolumeBackupApiToken($this, $member, ['write']);
$this->withToken($memberWriteToken)->putJson($endpoint, ['frequency' => 'daily'])->assertForbidden();
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('does not set schedules through resources owned by another team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApplication = Application::factory()->create([
'environment_id' => $otherEnvironment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherDatabase = StandalonePostgresql::create([
'name' => 'other-team-postgres',
'image' => 'postgres:17-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $otherEnvironment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherService = Service::factory()->create([
'environment_id' => $otherEnvironment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
foreach (['applications' => $otherApplication, 'databases' => $otherDatabase, 'services' => $otherService] as $type => $resource) {
$this->withHeaders($this->headers)
->putJson("/api/v1/{$type}/{$resource->uuid}/storages/{$this->volume->uuid}/backups", ['frequency' => 'daily'])
->assertNotFound();
}
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('does not set a schedule for storage outside the scoped parent', function () {
$otherApplication = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherVolume = LocalPersistentVolume::create([
'name' => 'other-application-volume',
'mount_path' => '/data',
'resource_id' => $otherApplication->id,
'resource_type' => $otherApplication->getMorphClass(),
]);
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$otherVolume->uuid}/backups", ['frequency' => 'daily'])
->assertNotFound();
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
@@ -95,3 +95,16 @@ test('owner can create root token', function () {
expect($token)->not->toBeNull()
->and($token->abilities)->toBe(['root']);
});
test('owner can deselect root and falls back to read permission', 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('permissions', ['root'])
->set('permissions', [])
->assertSet('permissions', ['read']);
});
@@ -0,0 +1,18 @@
<?php
it('shows API token permissions in a dropdown and keeps non-root options rendered', function () {
$view = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php'));
$permissionsSection = str($view)->between(
'<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>',
'</x-application.settings-section>'
)->toString();
expect($permissionsSection)
->toContain('permissionsOpen')
->toContain('Selected permissions')
->toContain('<x-forms.checkbox')
->toContain("in_array('root', \$permissions)")
->toContain('Read sensitive data')
->not->toContain('<input type="checkbox" value="root"')
->not->toContain("@if (!in_array('root', \$permissions))");
});
@@ -0,0 +1,9 @@
<?php
test('api tokens revoke action is text-only without a trash icon', function () {
$blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php'));
expect($blade)
->toContain('Revoke')
->not->toMatch('/name="trash"[\s\S]{0,80}Revoke|Revoke[\s\S]{0,80}name="trash"/');
});
+138
View File
@@ -0,0 +1,138 @@
<?php
test('custom color theme is available and applied across theme controls', function () {
$appearance = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
$accountMenu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
$deploymentLogs = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$serverTiming = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
$terminal = file_get_contents(resource_path('js/terminal.js'));
$styles = file_get_contents(resource_path('css/app.css'));
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($appearance)
->toContain("['value' => 'custom', 'label' => 'Custom'")
->toContain('type="color"')
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->toContain("localStorage.setItem('themeColor', this.themeColor)")
->toContain("this.theme === 'custom'")
->and($accountMenu)
->toContain("['value' => 'custom', 'label' => 'Custom']")
->toContain('aria-label="Custom theme color"')
->toContain("localStorage.setItem('themeColor', this.themeColor)")
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->and($layout)
->toContain("t === 'custom'")
->toContain("localStorage.themeColor || '#6b16ed'")
->toContain('--theme-accent-foreground')
->toContain('window.applyStoredTheme')
->toContain("document.addEventListener('livewire:navigated', window.applyStoredTheme)")
->and($styles)
->toContain('html[data-theme="custom"]')
->toContain('--theme-base-color: #6b16ed;')
->toContain('--theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, white);')
->toContain('--color-accent: var(--theme-bright-color);')
->toContain('--color-coollabs: var(--theme-bright-color);')
->toContain('--color-warning: var(--theme-bright-color);')
->toContain('--color-accent-foreground: var(--theme-accent-foreground);')
->toContain('--color-fg-dim: #e4dfea;')
->toContain('--color-fg-faint: #d8d2df;')
->toContain('html[data-theme="custom"] .control-selected')
->toContain('--theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground));')
->toContain('--theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, #52525b);')
->toContain('--theme-placeholder-color: color-mix(in srgb, white 20%, var(--theme-base-color));')
->toContain('html[data-theme="custom"] *')
->toContain('scrollbar-color: var(--theme-scrollbar-thumb) var(--color-panel);')
->toContain('html[data-theme="custom"] *::-webkit-scrollbar-thumb')
->toContain('html[data-theme="custom"] .application-settings-navigation')
->toContain('scrollbar-gutter: stable;')
->toContain('--color-panel: color-mix(in oklab, var(--theme-base-color) 14%, #0c0c0d);')
->toContain('--color-surface: color-mix(in oklab, var(--theme-base-color) 18%, #101011);')
->toContain('--coollabs-elevated: var(--color-surface);')
->toContain('--coollabs-recessed: var(--color-raised);')
->toContain('--coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, #111112);')
->toContain('--color-content-surface: var(--coollabs-base);')
->toContain('--coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, #18181a);')
->toContain('--color-nav-text: #e4dfea;')
->toContain('--color-nav-muted: #d8d2df;')
->toContain('--color-log: color-mix(in oklab, var(--theme-base-color) 20%, #080809);')
->toContain('--color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, #101011);')
->toContain('html[data-theme="custom"] .logs-viewer-toolbar')
->toContain('html[data-theme="custom"] .logs-viewer-timestamp')
->toContain('html[data-theme="custom"] #nprogress .bar')
->toContain('background: var(--theme-bright-color) !important;')
->toContain('html[data-theme="custom"] #nprogress .spinner-icon')
->toContain('html[data-theme="custom"] .loading-indicator')
->toContain('html[data-theme="custom"] .data-table')
->toContain('html[data-theme="custom"] [class~="dark:bg-white/[0.025]"]')
->toContain('html[data-theme="custom"] [class~="dark:bg-surface"]')
->toContain('background-color: var(--color-content-surface) !important;')
->toContain('.dark [class~="dark:border-white/[0.08]"]')
->toContain('.dark [class~="dark:border-white/[0.06]"]')
->toContain('border-color: var(--coollabs-hairline) !important;')
->toContain('html[data-theme="custom"] input::placeholder')
->toContain('color: var(--theme-placeholder-color) !important;')
->toMatch('/html\[data-theme="custom"\] input::placeholder,[^{]+\{[^}]*opacity: 0\.7;/s')
->toContain('html[data-theme="custom"] input:read-only')
->and($deploymentLogs)
->toContain('dark:bg-log')
->not->toContain('dark:bg-[#0d0d0d]')
->and($serverTiming)
->toContain('html[data-theme="custom"] #server-timing-hud')
->toContain('--sth-background: var(--color-surface);')
->toContain('--sth-livewire: var(--theme-bright-color);')
->and($utilities)
->toContain('text-nav-text')
->toContain('text-nav-muted')
->toContain('text-nav-active')
->and($terminal)
->toContain("dataset.theme === 'custom'")
->toContain("localStorage.getItem('themeColor')")
->toContain("attributeFilter: ['class', 'data-theme', 'style']")
->and($styles)
->toContain('html[data-theme="custom"] .application-console-shell[data-console-theme="system"]')
->toContain('--console-theme-background: var(--color-log);')
->toContain('--console-theme-border: var(--coollabs-line);');
});
test('navigation colors meet WCAG AA contrast requirements', function () {
$styles = file_get_contents(resource_path('css/app.css'));
$contrastRatio = function (string $foreground, string $background): float {
$luminance = function (string $color): float {
$channels = array_map(
fn (string $channel): float => hexdec($channel) / 255,
str_split(ltrim($color, '#'), 2),
);
$channels = array_map(
fn (float $channel): float => $channel <= 0.04045
? $channel / 12.92
: (($channel + 0.055) / 1.055) ** 2.4,
$channels,
);
return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
};
[$lighter, $darker] = collect([$luminance($foreground), $luminance($background)])
->sortDesc()
->values()
->all();
return ($lighter + 0.05) / ($darker + 0.05);
};
$themes = [
[['#525252', '#666666', '#171717'], '#ffffff'],
[['#a8a8b0', '#7a7a84', '#f2f2f2'], '#0c0c0d'],
[['#e4dfea', '#d8d2df', '#ffffff', '#e4dfea', '#d8d2df'], '#58585a'],
];
foreach ($themes as [$colors, $background]) {
foreach ($colors as $color) {
expect($styles)->toContain($color);
expect($contrastRatio($color, $background))->toBeGreaterThanOrEqual(4.5);
}
}
});
@@ -653,4 +653,161 @@ describe('GitHub Source Change Component', function () {
Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app');
});
test('isConnected is true only when app and installation are present', function () {
$incomplete = GithubApp::create([
'name' => 'Incomplete App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$connected = GithubApp::create([
'name' => 'Connected App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$public = new GithubApp([
'is_public' => true,
]);
expect($incomplete->isConnected())->toBeFalse()
->and($connected->isConnected())->toBeTrue()
->and($public->isConnected())->toBeTrue();
});
test('shows connected badge and test connection for installed github apps', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Connected GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('isConnected', true)
->assertSee('Connected')
->assertSee('Test Connection');
});
test('testConnection succeeds when github app credentials are valid', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Connected GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.com/app' => Http::response([
'name' => 'Coolify GitHub App',
'slug' => 'coolify-github-app',
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'ghs_test_installation_token',
]),
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('testConnection')
->assertDispatched('success', 'Connection successful! Authenticated as GitHub App: Coolify GitHub App');
});
test('testConnection fails when github app is not fully installed', function () {
$githubApp = GithubApp::create([
'name' => 'Incomplete GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('isConnected', false)
->call('testConnection')
->assertDispatched('error', 'GitHub App is not fully set up. Please complete installation first.');
});
test('sources list shows Connected for finished github apps', function () {
GithubApp::create([
'name' => 'Finished GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
GithubApp::create([
'name' => 'Incomplete 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,
'is_public' => false,
]);
$this->get(route('source.all'))
->assertSuccessful()
->assertSee('Finished GitHub App')
->assertSee('Connected')
->assertSee('Incomplete GitHub App')
->assertSee('Setup required');
});
});
@@ -0,0 +1,104 @@
<?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']);
});
/**
* Regression for #11079: removing all domains must save without TypeError from
* sslipDomainWarning(null) after normalizeApplicationDomains returns null.
*/
test('can clear application domains and save successfully', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'fqdn' => 'https://example.com',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'ports_exposes' => '3000',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('fqdn', null)
->call('submit')
->assertHasNoErrors()
->assertNotDispatched('error')
->assertDispatched('success');
$application->refresh();
expect($application->fqdn)->toBeNull();
});
test('can clear application domains via empty string and save successfully', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'fqdn' => 'https://app.example.com,https://www.example.com',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'ports_exposes' => '3000',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('fqdn', '')
->call('submit')
->assertHasNoErrors()
->assertNotDispatched('error')
->assertDispatched('success');
$application->refresh();
expect($application->fqdn)->toBeNull();
});
@@ -0,0 +1,19 @@
<?php
it('uses the shared status summary in the primary application server card', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
$applicationSection = str($view)->before('@else')->value();
expect($applicationSection)
->toContain('<x-status-summary :status="$resource->status" />')
->not->toContain('<x-status :resource="$resource"');
});
it('links each configured server card to its server page', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
expect($view)
->toContain("route('server.show', ['server_uuid' => data_get(\$resource, 'destination.server.uuid')])")
->toContain("route('server.show', ['server_uuid' => data_get(\$destination, 'server.uuid')])")
->toContain('Open server');
});
File diff suppressed because it is too large. Load diff
@@ -47,7 +47,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
test('existing application buildpack selector lists nixpacks before railpack', function () {
test('existing application buildpack selector lists railpack before nixpacks', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
@@ -62,12 +62,12 @@ test('existing application buildpack selector lists nixpacks before railpack', f
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSeeInOrder([
'<option value="railpack">Railpack</option>',
'<option value="nixpacks">Nixpacks</option>',
'<option value="railpack">Railpack (Beta)</option>',
], false);
});
test('existing application shows railpack beta label in build pack selector', function () {
test('existing application shows railpack without beta label in build pack selector', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
@@ -81,5 +81,7 @@ test('existing application shows railpack beta label in build pack selector', fu
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('Railpack (Beta)');
->assertSee('Railpack')
->assertDontSee('Railpack (Beta)')
->assertDontSee('Railpack (beta)');
});
@@ -0,0 +1,34 @@
<?php
test('compose actions are grouped with the application details header', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$applicationDetails = str($view)
->after('class="application-details-card">')
->before('</x-application.settings-section>')
->toString();
expect($applicationDetails)
->toContain('<x-slot:actions>')
->toContain('x-on:click="$wire.dispatch(\'loadCompose\', false)"')
->toContain('Reload compose');
});
test('docker compose heading separates its title and action', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
expect($view)
->toContain('<div x-data="{ showRaw: true }" class="mt-5">')
->toContain('<div class="flex items-center justify-between gap-4">');
});
test('onboarding uses the reusable advanced settings component', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$onboarding = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('id="dockerComposeCustomBuildCommand"')
->not->toContain('<x-forms.collapsible class="pt-4" content-class="grid gap-4">')
->not->toContain('The following commands are for advanced use cases.')
->and($onboarding)
->toContain('<x-forms.collapsible title="Advanced Connection Settings"');
});
@@ -0,0 +1,91 @@
<?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\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('uses card-style git source triggers instead of plain form buttons', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/source.blade.php'));
expect($view)
->toContain('<x-slot:trigger>')
->toContain('label="Current"')
->toContain('aria-current="true"')
->toContain('<x-git-icon')
->toContain('rounded-xl border')
->not->toContain('<x-slot:customButton>');
});
it('renders the current source as a selected card and alternatives as switchable cards', function () {
$current = GithubApp::create([
'name' => 'coolify-laravel-dev-public',
'organization' => 'coollabsio',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
'app_id' => 111,
'installation_id' => 222,
'client_id' => 'client',
'client_secret' => 'secret',
'webhook_secret' => 'webhook',
]);
$other = GithubApp::create([
'name' => 'coolify-examples-app',
'organization' => 'coollabsio',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
'app_id' => 333,
'installation_id' => 444,
'client_id' => 'client-2',
'client_secret' => 'secret-2',
'webhook_secret' => 'webhook-2',
]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'private_key_id' => null,
'source_id' => $current->id,
'source_type' => GithubApp::class,
'git_repository' => 'coollabsio/coolify-examples',
'git_branch' => 'v4.x',
]);
Livewire::test(Source::class, ['application' => $application])
->assertOk()
->assertSee('Git source', false)
->assertSee('coolify-laravel-dev-public', false)
->assertSee('Current', false)
->assertSee('GitHub · coollabsio', false)
->assertSee('coolify-examples-app', false)
->assertSee('Change Git source?', false);
});
@@ -0,0 +1,192 @@
<?php
use App\Livewire\Project\Application\Heading as ApplicationHeading;
use App\Livewire\Project\Application\Status as ApplicationStatus;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$this->routeParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
];
});
/**
* Settings tab must carry both the active class and aria-current so CSS
* under .application-heading-actions can override the base tab resets.
*/
function assertSettingsTabActive(string $html): void
{
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(1);
// Desktop navbar CSS override must exist so active styles are visible.
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain(".application-heading-actions .app-tab[aria-current='page']")
->toContain('.application-heading-actions .app-tab.app-tab-active');
}
it('marks settings tab active on general configuration route', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.configuration', $this->routeParams))
->assertSuccessful()
->getContent();
assertSettingsTabActive($html);
});
it('marks settings tab active on webhooks and other settings sub-routes', function (string $routeName) {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route($routeName, $this->routeParams))
->assertSuccessful()
->getContent();
assertSettingsTabActive($html);
})->with([
'webhooks' => 'project.application.webhooks',
'domains' => 'project.application.domains',
'advanced' => 'project.application.advanced',
'environment-variables' => 'project.application.environment-variables',
'danger' => 'project.application.danger',
]);
it('does not mark settings tab active on deployment logs', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.deployment.index', $this->routeParams))
->assertSuccessful()
->getContent();
expect($html)->toContain('Deployment');
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(0);
// Deployment now lives in the Logs section of the settings sidebar.
expect(preg_match(
'/<a[^>]*menu-item-active[^>]*>.*?Deployment.*?<\/a>/s',
$html
))->toBe(1);
});
it('syncs activeRouteName from the page route when heading is rendered on webhooks', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.webhooks', $this->routeParams))
->assertSuccessful()
->assertSeeLivewire(ApplicationHeading::class)
->getContent();
assertSettingsTabActive($html);
});
it('keeps activeRouteName when request is not an application page route', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(ApplicationHeading::class, ['application' => $this->application]);
$component->set('activeRouteName', 'project.application.webhooks');
$component->call('$refresh')
->assertSet('activeRouteName', 'project.application.webhooks');
});
it('refreshes the breadcrumb application status after it changes', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(ApplicationStatus::class, ['application' => $this->application])
->assertSee('Running');
$this->application->update(['status' => 'exited']);
$component
->call('refreshStatus')
->assertSee('Stopped')
->assertDontSee('Running');
expect($component->instance()->getListeners())
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
});
it('uses app-tab-active utility for resource heading active styles', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($utilities)->toContain('@utility app-tab-active');
});
@@ -0,0 +1,114 @@
<?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']);
});
function createApplicationForHttpBasicAuth(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => true,
'http_basic_auth_username' => 'admin',
'http_basic_auth_password' => 'secret',
'redirect' => 'no',
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'ports_exposes' => '3000',
], $overrides));
}
test('http basic auth form shows a single username and password without multi-user controls', function () {
$application = createApplicationForHttpBasicAuth();
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSet('isHttpBasicAuthEnabled', true)
->assertSet('httpBasicAuthUsername', 'admin')
->assertSee('Username')
->assertSee('Password')
->assertDontSee('Add user')
->assertDontSee('Remove user')
->assertDontSee('The default user cannot be removed');
});
test('http basic auth credentials can be saved through the general form', function () {
$application = createApplicationForHttpBasicAuth([
'http_basic_auth_username' => 'old-user',
'http_basic_auth_password' => 'old-pass',
]);
Livewire::test(General::class, ['application' => $application])
->set('httpBasicAuthUsername', 'new-user')
->set('httpBasicAuthPassword', 'new-pass')
->call('submit')
->assertHasNoErrors();
$application->refresh();
expect($application->http_basic_auth_username)->toBe('new-user')
->and($application->http_basic_auth_password)->toBe('new-pass')
->and((bool) $application->is_http_basic_auth_enabled)->toBeTrue();
});
test('disabling http basic auth is saved instantly', function () {
$application = createApplicationForHttpBasicAuth();
Livewire::test(General::class, ['application' => $application])
->set('isHttpBasicAuthEnabled', false)
->call('instantSave')
->assertHasNoErrors()
->assertSet('isHttpBasicAuthEnabled', false);
$application->refresh();
expect((bool) $application->is_http_basic_auth_enabled)->toBeFalse();
});
@@ -0,0 +1,56 @@
<?php
it('shows internal Docker access details in application general settings', function () {
$generalSettings = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$generalComponent = file_get_contents(app_path('Livewire/Project/Application/General.php'));
$internalAccessSettings = file_get_contents(resource_path('views/livewire/project/application/internal-access.blade.php'));
$internalAccessComponent = file_get_contents(app_path('Livewire/Project/Application/InternalAccess.php'));
$configurationSidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($generalSettings)
->toContain('id="access-section" title="Access"')
->toContain('<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Public access</h3>')
->toContain("Str::plural('domain', \$domainCount)")
->toContain('Domains, DNS checks, and redirect settings')
->toContain('class="flex items-center gap-3 rounded-lg')
->toContain('class="icon-button ml-auto shrink-0"')
->toContain('aria-label="Manage domains"')
->toContain('<x-reicon name="settings" class="size-4" />')
->not->toContain('<x-reicon name="arrow-right" class="size-3.5" />')
->toContain('<livewire:project.application.internal-access')
->not->toContain('wire:init="loadCurrentInternalHostname"')
->and($internalAccessSettings)
->toContain('id="internal-access-section"')
->toContain('<h3 class="mb-4 text-sm font-semibold text-black dark:text-fg">Internal access</h3>')
->not->toContain('<x-application.settings-section')
->toContain('Internal hostname')
->toContain('Docker network')
->toContain('Exposed ports')
->toContain('Network aliases')
->toContain('wire:init="loadCurrentInternalHostname"')
->toContain('$currentInternalHostname')
->toContain('class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"')
->not->toContain('Changes with each deployment')
->toContain("window.scrollToSettingsSection?.('networking-section')")
->and($generalComponent)
->not->toContain('public function loadCurrentInternalHostname(): void')
->and($internalAccessComponent)
->toContain('public ?string $currentInternalHostname = null;')
->toContain('public function loadCurrentInternalHostname(): void')
->toContain('getCurrentApplicationContainerStatus(')
->toContain("data_get(\$currentContainer, 'Names')")
->and($configurationSidebar)
->toContain("['id' => 'access-section', 'label' => 'Access']");
});
it('does not show the internal access section for Docker Compose applications', function () {
$generalSettings = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$configurationSidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($generalSettings)
->toContain("@if (\$buildPack !== 'dockercompose')")
->and($configurationSidebar)
->toContain("['id' => 'access-section', 'label' => 'Access']")
->not->toContain("['id' => 'public-access-section', 'label' => 'Public access']")
->not->toContain("['id' => 'internal-access-section', 'label' => 'Internal access']");
});
@@ -211,6 +211,117 @@ YAML;
->and($domains['backend']['domain'])->toStartWith('http://');
});
test('applicationParser stores domains under original hyphenated compose service names', function () {
$dockerCompose = <<<'YAML'
services:
another-service:
image: myapp/api:latest
environment:
- SERVICE_FQDN_ANOTHER_SERVICE=${API_URL}
analytics:
image: myapp/analytics:latest
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
'fqdn' => null,
'docker_compose_domains' => null,
]);
applicationParser($application);
$application->refresh();
$domains = json_decode($application->docker_compose_domains, true);
expect($domains)->toBeArray()
->and($domains)->toHaveKey('another-service')
->and($domains)->not->toHaveKey('another_service')
->and($domains['another-service']['domain'])->toStartWith('http://');
});
test('applicationParser preserves legacy underscore domain keys by matching hyphenated services', function () {
$dockerCompose = <<<'YAML'
services:
another-service:
image: myapp/api:latest
environment:
- SERVICE_FQDN_ANOTHER_SERVICE=${API_URL}
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
'fqdn' => null,
'docker_compose_domains' => json_encode([
'another_service' => ['domain' => 'https://legacy.example.com'],
]),
]);
applicationParser($application);
$application->refresh();
$domains = json_decode($application->docker_compose_domains, true);
// Existing domain is preserved (not overwritten) even when stored under legacy underscore key.
expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com');
});
test('applicationParser reads redirect settings from the compose service domain', function () {
$this->server->proxy->set('type', 'TRAEFIK');
$this->server->save();
ServerSetting::query()
->where('server_id', $this->server->id)
->update(['generate_exact_labels' => true]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
YAML,
'docker_compose_domains' => json_encode([
'frontend' => [
'domain' => 'https://example.com,https://www.example.com',
'redirect' => 'www',
],
]),
]);
$parsedCompose = applicationParser($application);
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=$${1}://www.$${2}')))->toBeTrue();
});
test('compose domain reconciliation preserves stored domains when parsing returns no services', function () {
$storedDomains = json_encode([
'frontend' => ['domain' => 'https://frontend.example.com'],
]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_domains' => $storedDomains,
]);
$method = new ReflectionMethod($application, 'reconcileDockerComposeDomains');
$method->invoke($application, ['services' => []]);
expect($application->fresh()->docker_compose_domains)->toBe($storedDomains);
});
test('applicationParser handles other docker compose domain shapes without regressions', function () {
$createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
return Application::factory()->create([
+81 -6
View File
@@ -1,17 +1,32 @@
<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Project\Application\Domains;
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;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
[
'id' => 0,
'is_dns_validation_enabled' => false,
]
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -19,6 +34,33 @@ beforeEach(function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
'ip' => '203.0.113.10',
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
@@ -27,11 +69,13 @@ describe('Application Redirect', function () {
test('setRedirect persists the redirect value to the database', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://example.com,https://www.example.com',
'redirect' => 'both',
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(Domains::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
@@ -41,20 +85,51 @@ describe('Application Redirect', function () {
expect($application->redirect)->toBe('www');
});
test('setRedirect rejects www redirect when no www domain exists', function () {
test('setRedirect auto-adds missing www domain instead of rejecting', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://example.com',
'redirect' => 'both',
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(Domains::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
->assertDispatched('error');
->assertDispatched('success');
$application->refresh();
expect($application->redirect)->toBe('both');
expect($application->redirect)->toBe('www')
->and(explode(',', (string) $application->fqdn))
->toContain('https://example.com')
->toContain('https://www.example.com');
});
test('setRedirect only treats hostname-leading www as www and auto-adds the real pair', function (string $fqdn, string $expectedWww) {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => $fqdn,
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
->assertDispatched('success');
$application->refresh();
expect($application->redirect)->toBe('www')
->and(explode(',', (string) $application->fqdn))
->toContain($fqdn)
->toContain($expectedWww);
})->with([
'www in path' => ['https://example.com/www.example.com', 'https://www.example.com/www.example.com'],
'www in unrelated hostname label' => ['https://app.www.example.com', 'https://www.app.www.example.com'],
]);
});
@@ -0,0 +1,16 @@
<?php
/**
* Application configuration Rollback nav must use the reicon "time-back" glyph.
*/
test('application rollback menu uses the time-back icon', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
$reicon = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($configuration)
->toMatch("/'Rollback'\\s*=>\\s*'time-back'/")
->not->toMatch("/'Rollback'\\s*=>\\s*'logout'/");
expect($reicon)
->toContain("'time-back' =>");
});
@@ -0,0 +1,73 @@
<?php
use App\Livewire\Project\Application\Source;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('a GitLab source is not hidden by a GitHub source sharing the same numeric id', function () {
$githubApp = GithubApp::create([
'name' => 'gh',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
'app_id' => 123,
]);
$gitlabApp = GitlabApp::create([
'name' => 'gl',
'team_id' => $this->team->id,
'api_url' => 'https://gitlab.example.test/api/v4',
'html_url' => 'https://gitlab.example.test',
'is_public' => false,
'access_token' => 'token',
'refresh_token' => 'refresh',
'expires_at' => time() + 3600,
]);
// The two source tables auto-increment independently, so the first row in each shares id 1.
expect($gitlabApp->id)->toBe($githubApp->id);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'private_key_id' => null,
'source_id' => $githubApp->id,
'source_type' => GithubApp::class,
]);
$component = Livewire::test(Source::class, ['application' => $application]);
$sources = $component->get('sources');
expect($sources->contains(fn ($s) => $s instanceof GitlabApp && $s->id === $gitlabApp->id))->toBeTrue();
expect($sources->contains(fn ($s) => $s instanceof GithubApp && $s->id === $githubApp->id))->toBeFalse();
// The GitLab source renders as selectable, not flagged as the current GitHub source despite the shared id.
$component->assertSee('gl')->assertDontSee('(current)');
});
@@ -1,5 +1,6 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -7,6 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->personal()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -59,7 +61,8 @@ it('renders 419 error page with login link instead of previous url', function ()
$view = view('errors.419')->render();
expect($view)->toContain('/login');
expect($view)->toContain('Back to Login');
expect($view)->toContain('Back to login');
expect($view)->toContain('This page is definitely old, not like you!');
expect($view)->toContain('error-shell');
expect($view)->not->toContain('url()->previous()');
});
@@ -77,13 +77,13 @@ describe('POST /api/v1/servers', function () {
});
});
describe('GET /api/v1/servers/{uuid}/validate', function () {
describe('POST /api/v1/servers/{uuid}/validate', function () {
test('read-only token cannot trigger server validation', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/servers/fake-uuid/validate');
])->postJson('/api/v1/servers/fake-uuid/validate');
$response->assertStatus(403);
});
@@ -164,7 +164,7 @@ test('admin dev view shows unlocked env value', function () {
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
])->call('switch');
expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value');
});
@@ -175,7 +175,7 @@ test('admin dev view hides locked env value', function () {
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
])->call('switch');
expect($component->get('variables'))->toContain('LOCKED_VAR=(Locked Secret, delete and add again to change)');
expect($component->get('variables'))->not->toContain('secret-locked-value');
@@ -187,7 +187,7 @@ test('member dev view hides all env values', function () {
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
])->call('switch');
expect($component->get('variables'))->not->toContain('secret-unlocked-value');
expect($component->get('variables'))->not->toContain('secret-locked-value');
@@ -10,7 +10,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->owner = User::factory()->create();
$this->personalTeam = $this->owner->teams()->first();
@@ -44,3 +44,16 @@ test('member cannot delete team via navbar', function () {
expect(Team::find($this->teamToDelete->id))->not->toBeNull();
});
test('navbar delete team trigger matches sidebar menu item layout', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(NavbarDeleteTeam::class)
->assertSuccessful()
->assertSee('title="Delete Team"', false)
->assertSee('menu-item justify-start text-left', false)
->assertSee('sidebar-collapsed-label', false)
->assertSee('menu-item-icon', false)
->assertDontSeeHtml('buttonTitle="Delete Team"');
});
@@ -3,21 +3,28 @@
use App\Livewire\Project\Shared\ResourceOperations;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
// Team A (attacker's team)
$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]);
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail();
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
@@ -30,7 +37,7 @@ beforeEach(function () {
// Team B (victim's team)
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail();
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
@@ -40,7 +47,7 @@ beforeEach(function () {
test('cloneTo rejects destination belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationB->id)
->call('cloneTo', $this->destinationB->uuid)
->assertHasErrors('destination_id');
// Ensure no cross-tenant application was created
@@ -48,12 +55,36 @@ test('cloneTo rejects destination belonging to another team', function () {
});
test('cloneTo allows destination belonging to own team', function () {
$secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$secondDestination = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'second-destination',
]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $secondDestination->id)
->assertHasNoErrors('destination_id')
->call('cloneTo', $secondDestination->uuid)
->assertHasNoErrors('destination_id');
expect(Application::count())->toBe(2);
});
test('cloneTo can place the cloned resource in another environment', function () {
$targetEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationA->uuid, $targetEnvironment->id)
->assertRedirect();
$clone = Application::whereKeyNot($this->applicationA->id)->firstOrFail();
expect($clone->environment_id)->toBe($targetEnvironment->id);
});
test('cloneTo rejects an environment belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationA->uuid, $this->environmentB->id)
->assertHasErrors('environment_id');
expect(Application::count())->toBe(1);
});
test('moveTo rejects environment belonging to another team', function () {
@@ -11,7 +11,10 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::query()->delete();
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
$this->team = Team::factory()->create(['personal_team' => false]);
@@ -138,6 +141,21 @@ test('owner can update team MCP setting', function () {
expect($this->team->fresh()->is_mcp_server_enabled)->toBeFalse();
});
test('team index mounts when is_mcp_server_enabled is null on the session team', function () {
$this->actingAs($this->owner);
// Simulate Team::create() without hydrating the DB default into session.
$this->team->setRawAttributes(array_merge(
$this->team->getAttributes(),
['is_mcp_server_enabled' => null],
));
session(['currentTeam' => $this->team]);
Livewire::test(TeamIndex::class)
->assertSuccessful()
->assertSet('is_mcp_server_enabled', true);
});
// --- Team Index Livewire: delete ---
test('member cannot delete team via index', function () {
+348 -5
View File
@@ -1,5 +1,6 @@
<?php
use App\Jobs\DatabaseBackupJob;
use App\Livewire\Project\Database\BackupEdit;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -12,6 +13,7 @@ use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -72,6 +74,258 @@ beforeEach(function () {
session(['currentTeam' => $this->team]);
});
it('renders a highlighted enable backup button and a regular disable backup button', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
expect($view)
->toContain('wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>')
->toContain('wire:target="toggleEnabled">Disable Backup</x-forms.button>')
->not->toContain('label="Backup Enabled"')
->and($s3View)
->toContain('wire:target="toggleS3" isHighlighted')
->toContain('wire:target="toggleS3">Disable S3</x-forms.button>')
->not->toContain('label="S3 Enabled"');
});
it('enables and disables S3 backups from the S3 title action', function () {
$s3 = createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => $s3->id,
]);
$component = Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => $this->team->s3s,
'section' => 's3',
])
->assertSee('Enable S3')
->call('toggleS3')
->assertSet('saveS3', true)
->assertSee('Disable S3');
expect($backup->refresh()->save_s3)->toBeTruthy();
$component->call('toggleS3')->assertSet('saveS3', false);
expect($backup->refresh()->save_s3)->toBeFalsy();
});
it('shows and saves S3 retention while S3 backups are disabled', function () {
$backup = createBackupForEditValidationTest($this->team, [
'enabled' => false,
'save_s3' => false,
'database_backup_retention_amount_locally' => 0,
'database_backup_retention_days_locally' => 0,
'database_backup_retention_max_storage_locally' => 0,
'database_backup_retention_amount_s3' => 0,
'database_backup_retention_days_s3' => 0,
'database_backup_retention_max_storage_s3' => 0,
'dump_all' => false,
'timeout' => 3600,
]);
Livewire::test(BackupEdit::class, [
'backup' => $backup,
'availableS3Storages' => $this->team->s3s,
'section' => 'retention',
])
->assertSee('S3 Storage Retention')
->set('databaseBackupRetentionAmountS3', 12)
->set('databaseBackupRetentionDaysS3', 30)
->set('databaseBackupRetentionMaxStorageS3', 4.5)
->call('submit')
->assertHasNoErrors();
expect($backup->refresh()->save_s3)->toBeFalsy()
->and($backup->database_backup_retention_amount_s3)->toBe(12)
->and($backup->database_backup_retention_days_s3)->toBe(30)
->and($backup->database_backup_retention_max_storage_s3)->toBe(4.5);
});
it('splits standalone database backup settings and executions across dedicated urls', function () {
config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']);
$backup = createBackupForEditValidationTest($this->team);
$database = $backup->database;
$parameters = [
'project_uuid' => $database->project()->uuid,
'environment_uuid' => $database->environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
];
$generalUrl = route('project.database.backup.execution', $parameters);
$this->get($generalUrl)
->assertOk()
->assertSee('General')
->assertSee('S3')
->assertSee('Retention')
->assertSee('Executions')
->assertSee('Danger Zone')
->assertSee('Frequency')
->assertDontSee('S3 Enabled')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups')
->assertDontSee('Delete Backups and Schedule');
$this->get($generalUrl.'/s3')
->assertOk()
->assertSeeText('No validated S3 storage')
->assertDontSee('Disable Local Backup')
->assertDontSee('Enable S3')
->assertDontSee('Disable S3')
->assertDontSee('S3 Storage Retention')
->assertDontSee('Local Backup Retention')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
expect(strpos($s3View, '<span>S3 Storage</span>'))
->toBeLessThan(strpos($s3View, 'label="Disable Local Backup"'));
$this->get($generalUrl.'/retention')
->assertOk()
->assertSee('Local Backup Retention')
->assertSee('S3 Storage Retention')
->assertSee('Number of backups to keep')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
$this->get($generalUrl.'/executions')
->assertOk()
->assertSee('<h2 class="py-0">Executions</h2>', false)
->assertDontSee('Executions <span', false)
->assertSee('Cleanup Failed Backups')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep');
$this->get($generalUrl.'/danger')
->assertOk()
->assertSee('Danger Zone')
->assertSee('Delete Scheduled Backup')
->assertSee('Delete Backups and Schedule')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups');
});
it('enables and disables a scheduled database backup from the title action', function () {
$backup = createBackupForEditValidationTest($this->team, [
'enabled' => false,
'save_s3' => false,
]);
$component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSet('backupEnabled', false)
->assertSee('Enable Backup')
->call('toggleEnabled')
->assertSet('backupEnabled', true)
->assertSee('Disable Backup');
expect($backup->refresh()->enabled)->toBeTruthy();
$component->call('toggleEnabled')->assertSet('backupEnabled', false);
expect($backup->refresh()->enabled)->toBeFalsy();
});
it('redirects to executions after queuing a database backup with unusable S3 storage', function () {
Queue::fake();
$s3Storage = createS3StorageForBackupEditValidationTest($this->team, 'Unavailable storage');
$s3Storage->update(['is_usable' => false]);
$backup = createBackupForEditValidationTest($this->team, [
'enabled' => true,
's3_storage_id' => $s3Storage->id,
'database_backup_retention_amount_locally' => 7,
'database_backup_retention_days_locally' => 0,
'database_backup_retention_max_storage_locally' => 0,
'database_backup_retention_amount_s3' => 7,
'database_backup_retention_days_s3' => 0,
'database_backup_retention_max_storage_s3' => 0,
'dump_all' => false,
'timeout' => 3600,
]);
$database = $backup->database;
$parameters = [
'project_uuid' => $database->project()->uuid,
'environment_uuid' => $database->environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
];
Livewire::test(BackupEdit::class, [
'backup' => $backup,
'availableS3Storages' => $this->team->s3s,
])
->call('backupNow')
->assertRedirectToRoute('project.database.backup.executions', $parameters);
Queue::assertPushed(DatabaseBackupJob::class);
});
it('queues instance database backup without redirecting when project context is missing', function () {
Queue::fake();
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$this->user->teams()->syncWithoutDetaching([$rootTeam->id => ['role' => 'owner']]);
session(['currentTeam' => $rootTeam]);
$server = Server::factory()->create([
'id' => 0,
'team_id' => $rootTeam->id,
'ip' => '127.0.0.1',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::create([
'id' => 0,
'name' => 'coolify',
'uuid' => (string) str()->uuid(),
'network' => 'coolify',
'server_id' => $server->id,
]);
$database = new StandalonePostgresql;
$database->forceFill([
'id' => 0,
'name' => 'coolify-db',
'description' => 'Coolify database',
'postgres_user' => 'coolify',
'postgres_password' => 'password',
'postgres_db' => 'coolify',
'status' => 'running',
'destination_type' => StandaloneDocker::class,
'destination_id' => $destination->id,
'environment_id' => null,
]);
$database->save();
expect($database->project())->toBeNull()
->and($database->environment)->toBeNull();
$backup = ScheduledDatabaseBackup::create([
'id' => 0,
'enabled' => true,
'save_s3' => false,
'frequency' => '0 0 * * *',
'database_type' => StandalonePostgresql::class,
'database_id' => $database->id,
'team_id' => $rootTeam->id,
'timeout' => 3600,
]);
Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => collect(),
])
->call('backupNow')
->assertDispatched('success', 'Backup queued. It will be available in a few minutes.')
->assertNoRedirect()
->assertHasNoErrors();
Queue::assertPushed(DatabaseBackupJob::class);
});
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
@@ -162,19 +416,52 @@ it('shows available S3 storages even when S3 backup is disabled', function () {
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSee('First S3')
->assertSee('Second S3');
});
it('shows disabled S3 storage dropdown when no storages are available', function () {
it('shows only an empty S3 state when no storages are available', function () {
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('No S3 storage available');
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSeeHtml('<h2>S3 storage</h2>')
->assertSeeText('No validated S3 storage')
->assertSeeHtml('href="'.route('storage.index').'"')
->assertSeeText('Open S3 storage')
->assertDontSee('Save')
->assertDontSee('Enable S3')
->assertDontSee('Disable S3')
->assertDontSee('S3 Storage')
->assertDontSee('Disable Local Backup')
->assertDontSee('No S3 storage available');
});
it('allows S3 backups to be disabled when no usable storage remains', function () {
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => true,
's3_storage_id' => null,
]);
$component = Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => collect(),
'section' => 's3',
])
->assertSet('saveS3', true)
->assertSeeText('No validated S3 storage')
->assertDontSee('Disable S3')
->call('toggleS3')
->assertDispatched('success')
->assertSet('saveS3', false)
->assertDontSee('Enable S3');
expect($backup->refresh()->save_s3)->toBeFalsy()
->and($backup->s3_storage_id)->toBeNull();
});
it('shows when S3 backups are currently disabled', function () {
@@ -184,7 +471,7 @@ it('shows when S3 backups are currently disabled', function () {
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSee('S3 Storage')
->assertSee('(currently disabled)');
});
@@ -205,3 +492,59 @@ it('saves selected S3 storage immediately when it changes', function () {
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBe($secondS3->id);
});
it('subscribes to database status broadcasts so Backup Now can refresh without a full page reload', function () {
$component = app(BackupEdit::class);
$method = new ReflectionMethod($component, 'getListeners');
$method->setAccessible(true);
$listeners = (array) $method->invoke($component);
expect($listeners)
->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked")
->toHaveKey('databaseUpdated');
});
it('shows Backup Now after refresh when the database becomes running', function () {
$backup = createBackupForEditValidationTest($this->team, [
'enabled' => true,
]);
$database = $backup->database;
$database->update(['status' => 'exited:unhealthy']);
$component = Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => $this->team->s3s,
'status' => 'exited:unhealthy',
])
->assertDontSee('Backup Now')
->assertSet('status', 'exited:unhealthy');
$database->update(['status' => 'running:healthy']);
$component->call('refreshStatus')
->assertSet('status', 'running:healthy')
->assertSee('Backup Now');
});
it('hides Backup Now after refresh when the database stops', function () {
$backup = createBackupForEditValidationTest($this->team, [
'enabled' => true,
]);
$database = $backup->database;
$database->update(['status' => 'running:healthy']);
$component = Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => $this->team->s3s,
'status' => 'running:healthy',
])
->assertSee('Backup Now')
->assertSet('status', 'running:healthy');
$database->update(['status' => 'exited:unhealthy']);
$component->call('refreshStatus')
->assertSet('status', 'exited:unhealthy')
->assertDontSee('Backup Now');
});
@@ -2,12 +2,14 @@
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\DatabaseBackupJob;
use App\Models\S3Storage;
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;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
@@ -235,6 +237,55 @@ test('deleteOldBackupsLocally identifies correct backups for deletion by retenti
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
});
test('database S3 retention remains best effort when deletion fails', function () {
$team = Team::factory()->create();
$s3 = 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' => $team->id,
]);
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
'disable_local_backup' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
'database_backup_retention_amount_s3' => 1,
]);
$newestExecution = ScheduledDatabaseBackupExecution::create([
'uuid' => 'newest-s3-backup',
'database_name' => 'test_db',
'filename' => '/backup/newest.dmp',
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
's3_uploaded' => true,
]);
$oldExecution = ScheduledDatabaseBackupExecution::create([
'uuid' => 'old-s3-backup',
'database_name' => 'test_db',
'filename' => '/backup/old.dmp',
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
's3_uploaded' => true,
]);
ScheduledDatabaseBackupExecution::whereKey($newestExecution->id)->update(['created_at' => now()]);
ScheduledDatabaseBackupExecution::whereKey($oldExecution->id)->update(['created_at' => now()->subDay()]);
$disk = Mockery::mock();
$disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse();
Storage::shouldReceive('build')->once()->andReturn($disk);
removeOldBackups($backup);
expect($oldExecution->fresh()->s3_storage_deleted)->toBeFalse()
->and($backup->executions()->count())->toBe(2);
});
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
Cache::forget('backup-retention-enforcement');
+189
View File
@@ -0,0 +1,189 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\S3Storage;
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;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
InstanceSettings::forceCreate(['id' => 0]);
$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('renders frontend-only application backup search data for volume names and frequencies', function () {
$application = createBackupSearchApplication($this->team);
$dailyVolume = createBackupSearchVolume($application, 'app-data');
$weeklyVolume = createBackupSearchVolume($application, 'Cache-Data');
$dailyVolume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
]);
$weeklyVolume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => '0 4 * * 0',
]);
$parameters = [
'project_uuid' => $application->project()->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
];
$this->get(route('project.application.backup.index', [...$parameters, 'search' => 'cache']))
->assertOk()
->assertSee('Cache-Data')
->assertSee('app-data')
->assertSee("search: 'cache'", false)
->assertSee('x-model="search"', false)
->assertSee('x-show=', false)
->assertSee('No scheduled backups match your search.')
->assertSee('Filter')
->assertSee('Sort')
->assertSee('All targets')
->assertSee('Target A–Z')
->assertSee('filterOpen')
->assertSee('sortOpen')
->assertDontSee('class="font-mono text-xs">daily', false)
->assertDontSee('backup-type-filter-trigger', false)
->assertDontSee('backup-sort-trigger', false);
});
it('renders frontend-only database backup search data for database names and frequencies', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create([
'name' => 'Orders-Primary',
'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(),
]);
$storage = S3Storage::create([
'name' => 'Archive-Bucket',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => $this->team->id,
]);
$database->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
'save_s3' => true,
's3_storage_id' => $storage->id,
]);
$database->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => '0 3 * * 1',
]);
$parameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
];
$this->get(route('project.database.backup.index', [...$parameters, 'search' => '0 3']))
->assertOk()
->assertSee('0 3 * * 1')
->assertSee('daily')
->assertSee('Archive-Bucket')
->assertSee('archive-bucket', false)
->assertSee('backup.s3_storage.includes(query)', false)
->assertSee('Search backup schedules')
->assertSee('x-model="search"', false)
->assertSee('x-show=', false)
->assertSee('No matching backup schedules');
});
it('renders unavailable S3 storage only for S3-enabled database backups', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create([
'name' => 'Orders-Primary',
'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(),
]);
$database->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
'save_s3' => true,
's3_storage_id' => null,
]);
$database->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'weekly',
'save_s3' => false,
's3_storage_id' => null,
]);
$parameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
];
$response = $this->get(route('project.database.backup.index', $parameters))
->assertOk()
->assertSee('Unavailable')
->assertSee('Local only');
// Only the S3-enabled schedule without a linked storage shows "Unavailable".
expect(substr_count($response->getContent(), 'Unavailable'))->toBe(1);
});
function createBackupSearchApplication(Team $team): Application
{
$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]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
return $application;
}
function createBackupSearchVolume(Application $application, string $name): LocalPersistentVolume
{
return LocalPersistentVolume::create([
'name' => $name,
'mount_path' => '/'.str($name)->lower(),
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
}
@@ -0,0 +1,74 @@
<?php
test('boarding utility actions are centered', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)->toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"');
});
test('server validation opens in the centered process dialog', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('<x-process-dialog closeWithX size="xl">')
->toContain('@click="processDialogOpen = true"')
->not->toContain('<x-slide-over closeWithX fullScreen>');
});
test('DigitalOcean is available as an onboarding server provider', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('<x-modal-input title="Connect a DigitalOcean Server" isFullWidth>')
->toContain('<x-digital-ocean-icon class="size-10 shrink-0" />')
->toContain('Deploy servers directly from your DigitalOcean account.')
->toContain('<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />');
});
test('server type details are shown on the relevant cards instead of a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('aria-label="About this machine"')
->toContain('aria-label="About remote servers"')
->toContain('Not recommended for production workloads due to resource contention.')
->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.')
->not->toContain('<x-highlighted text="Servers" />')
->not->toContain('<x-highlighted text="Localhost:" />')
->not->toContain('<x-highlighted text="Remote Server:" />');
});
test('server type cards use the standard card hover treatment', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
$hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md';
preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches);
expect($matches[0])->toHaveCount(5)
->and(substr_count($view, 'group relative cursor-pointer'))->toBe(5)
->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]')
->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]');
});
test('existing SSH key selection does not show a redundant value tooltip', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"');
});
test('server connection step does not render a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->not->toContain('<x-highlighted text="Connection Requirements:" />')
->not->toContain('<x-highlighted text="Hostname Resolution:" />')
->not->toContain('<x-highlighted text="User Permissions:" />');
});
test('project step does not render a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->not->toContain('<x-highlighted text="Project Organization:" />')
->not->toContain('<x-highlighted text="Environments:" />')
->not->toContain('<x-highlighted text="Team Access:" />');
});
@@ -0,0 +1,46 @@
<?php
test('breadcrumb switchers indicate the current item with background only', function () {
$views = collect([
resource_path('views/components/top-breadcrumb.blade.php'),
resource_path('views/components/breadcrumb-switcher.blade.php'),
resource_path('views/livewire/switch-team.blade.php'),
])->map(fn (string $path): string => file_get_contents($path));
expect($views->implode("\n"))
->not->toContain('name="check-circle"')
->and($views->every(fn (string $view): bool => str_contains($view, 'bg-neutral-100')))
->toBeTrue();
});
test('teleported resource breadcrumbs use the same spacing as application breadcrumbs', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"')
->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"');
});
test('top breadcrumbs shrink and clip instead of overlapping on narrower screens', function () {
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($breadcrumb)
->toContain('class="flex w-full min-w-0 items-center gap-0.5 text-[13px]"')
->not->toContain('overflow-hidden text-[13px]')
->toContain('class="min-w-0 shrink" x-data="{ collapsed: false }"')
->not->toContain('class="shrink-0" x-data="{ collapsed: false }"')
->and($layout)
->toContain('class="relative flex min-w-0 flex-1 items-center"')
->not->toContain('flex min-w-0 flex-1 items-center overflow-hidden')
->not->toContain('<div class="flex-1"></div>');
});
test('resource headings do not duplicate database and service breadcrumbs', function () {
$headings = collect([
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
])->map(fn (string $path): string => file_get_contents($path));
expect($headings->implode("\n"))->not->toContain("@teleport('#server-topbar-context')");
});
@@ -0,0 +1,36 @@
<?php
/**
* Terminal-related UI must use the redesign "browser-terminal" reicon, not the
* older "terminal" (four-way arrows) glyph.
*/
test('terminal and console views use the browser-terminal icon', function () {
$files = [
resource_path('views/components/navbar.blade.php'),
resource_path('views/components/backup-sidebar.blade.php'),
resource_path('views/components/server/sidebar-security.blade.php'),
resource_path('views/livewire/terminal/index.blade.php'),
resource_path('views/livewire/project/shared/terminal.blade.php'),
resource_path('views/livewire/project/shared/execute-container-command.blade.php'),
resource_path('views/livewire/project/shared/scheduled-task/all.blade.php'),
resource_path('views/livewire/project/shared/scheduled-task/executions.blade.php'),
resource_path('views/livewire/project/database/backup-executions.blade.php'),
resource_path('views/livewire/server/security/terminal-access.blade.php'),
];
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)
->not->toMatch('/name=["\']terminal["\']/')
->not->toMatch('/icon-name=["\']terminal["\']/')
->not->toMatch("/'icon'\\s*=>\\s*'terminal'/");
}
});
test('reicon pack includes browser-terminal glyph', function () {
$path = resource_path('views/components/reicon.blade.php');
$contents = file_get_contents($path);
expect($contents)->toContain("'browser-terminal' =>");
});
@@ -0,0 +1,154 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
});
test('it previews eligible unverified users without deleting them', function () {
$user = User::factory()->unverified()->create([
'email' => 'unverified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users')
->expectsOutput('Found 1 unverified user eligible for deletion.')
->expectsOutput('Dry run only. Use --yes to delete eligible users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it reports dry run progress', function () {
$users = User::factory()->unverified()->count(2)->create();
$exitCode = Artisan::call('cloud:cleanup-unverified-users');
expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('Checking eligible users: 2/2');
$users->each(fn (User $user) => $this->assertModelExists($user));
});
test('it deletes eligible unverified users with the yes option', function () {
$user = User::factory()->unverified()->create([
'email' => 'unverified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 1 unverified user.')
->assertSuccessful();
$this->assertModelMissing($user);
});
test('it reports deletion progress', function () {
User::factory()->unverified()->count(2)->create();
$exitCode = Artisan::call('cloud:cleanup-unverified-users', ['--yes' => true]);
expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('Deleting eligible users: 2/2');
});
test('it keeps verified users', function () {
$user = User::factory()->create([
'email' => 'verified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with a Stripe subscription record', function () {
$user = User::factory()->unverified()->create([
'email' => 'subscribed@example.com',
]);
Subscription::create([
'team_id' => $user->teams()->firstOrFail()->id,
'stripe_invoice_paid' => false,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with defined resources', function () {
$user = User::factory()->unverified()->create([
'email' => 'resource-owner@example.com',
]);
$project = Project::factory()->create([
'team_id' => $user->teams()->firstOrFail()->id,
]);
$environment = Environment::factory()->create([
'project_id' => $project->id,
]);
Application::factory()->create([
'environment_id' => $environment->id,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with servers', function () {
$user = User::factory()->unverified()->create([
'email' => 'server-owner@example.com',
]);
Server::factory()->create([
'team_id' => $user->teams()->firstOrFail()->id,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified root team members', function () {
$user = User::factory()->unverified()->create([
'email' => 'root-member@example.com',
]);
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
]);
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it only runs on Coolify Cloud', function () {
config()->set('constants.coolify.self_hosted', true);
$this->artisan('cloud:cleanup-unverified-users')
->expectsOutput('This command can only be run on Coolify Cloud.')
->assertFailed();
});
@@ -0,0 +1,101 @@
<?php
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('backups');
config()->set('constants.coolify.self_hosted', false);
});
test('it exports subscribed and unsubscribed verified users to separate files without tags', function () {
User::factory()->create([
'id' => 0,
'name' => 'Root User',
'email' => 'root@example.com',
]);
$subscribedUser = User::factory()->create([
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
]);
Subscription::create([
'team_id' => $subscribedUser->teams()->firstOrFail()->id,
'stripe_invoice_paid' => true,
'stripe_subscription_id' => 'sub_active',
]);
User::factory()->create([
'name' => 'Grace',
'email' => 'grace@example.com',
]);
User::factory()->unverified()->create([
'name' => 'Unverified User',
'email' => 'unverified@example.com',
]);
Storage::disk('backups')->put('cloud-users.csv', 'old export');
$this->artisan('cloud:export-users')->assertSuccessful();
Storage::disk('backups')->assertExists([
'cloud-users-subscribed.csv',
'cloud-users-unsubscribed.csv',
]);
Storage::disk('backups')->assertMissing('cloud-users.csv');
$readCsv = function (string $filename): array {
$output = fopen(Storage::disk('backups')->path($filename), 'rb');
$rows = [];
while (($row = fgetcsv($output, null, ',', '"', '')) !== false) {
$rows[] = $row;
}
fclose($output);
return $rows;
};
$header = [
'email',
'first_name',
'last_name',
'lifetime_value_currency',
'lifetime_value_amount',
'utm_campaign',
'utm_source',
'utm_medium',
'utm_content',
'utm_term',
'phone',
];
expect($readCsv('cloud-users-subscribed.csv'))->toBe([
$header,
['ada@example.com', 'Ada', 'Lovelace', '', '', '', '', '', '', '', ''],
])->and($readCsv('cloud-users-unsubscribed.csv'))->toBe([
$header,
['grace@example.com', 'Grace', '', '', '', '', '', '', '', '', ''],
]);
});
test('it only runs on Coolify Cloud', function () {
config()->set('constants.coolify.self_hosted', true);
$this->artisan('cloud:export-users')
->expectsOutput('This command can only be run on Coolify Cloud.')
->assertFailed();
Storage::disk('backups')->assertMissing([
'cloud-users.csv',
'cloud-users-subscribed.csv',
'cloud-users-unsubscribed.csv',
]);
});
@@ -0,0 +1,127 @@
<?php
use App\Support\DomainConnect\CloudflareDomainConnect;
function domainConnectTestKeyPair(): array
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
$details = openssl_pkey_get_details($key);
return [
'private' => $privateKey,
'public' => $details['key'],
];
}
beforeEach(function () {
$pair = domainConnectTestKeyPair();
$this->privateKey = $pair['private'];
$this->publicKey = $pair['public'];
config([
'constants.coolify.self_hosted' => false, // isCloud()
'services.domain_connect.provider_id' => 'coolify.io',
'services.domain_connect.service_id' => 'hosting',
'services.domain_connect.key_id' => '_dcpubkeyv1',
'services.domain_connect.private_key' => $this->privateKey,
]);
});
it('is configured when a private key is present', function () {
$connect = new CloudflareDomainConnect;
expect($connect->isConfigured())->toBeTrue();
config(['services.domain_connect.private_key' => null]);
expect((new CloudflareDomainConnect)->isConfigured())->toBeFalse();
});
it('builds a signed cloudflare domain connect apply url like resend', function () {
$connect = new CloudflareDomainConnect;
$url = $connect->buildHostingApplyUrl(
domain: 'example.com',
ip: '203.0.113.10',
host: 'app',
);
expect($url)
->toStartWith('https://dash.cloudflare.com/domainconnect/v2/domainTemplates/providers/coolify.io/services/hosting/apply?')
->toContain('domain=example.com')
->toContain('host=app')
->toContain('ip=203.0.113.10')
->toContain('key=_dcpubkeyv1')
->toContain('sig=');
$query = parse_url($url, PHP_URL_QUERY);
parse_str($query, $params);
expect($params)
->toHaveKey('domain')
->toHaveKey('host')
->toHaveKey('ip')
->toHaveKey('key')
->toHaveKey('sig');
// Signature must be last query parameter (Cloudflare requirement).
expect(str_ends_with($query, 'sig='.rawurlencode($params['sig'])) || str_ends_with($query, 'sig='.$params['sig']))
->toBeTrue();
// Verify RSA-SHA256 over the query excluding key and sig.
$parts = [];
foreach (explode('&', $query) as $part) {
if (str_starts_with($part, 'key=') || str_starts_with($part, 'sig=')) {
continue;
}
$parts[] = $part;
}
$signedPayload = implode('&', $parts);
$ok = openssl_verify(
$signedPayload,
base64_decode($params['sig'], true),
$this->publicKey,
OPENSSL_ALGO_SHA256,
);
expect($ok)->toBe(1);
});
it('includes empty host for apex domains', function () {
$connect = new CloudflareDomainConnect;
$url = $connect->buildHostingApplyUrl(
domain: 'example.com',
ip: '203.0.113.10',
host: null,
);
expect($url)->toContain('host=');
});
it('splits hostnames into domain and host', function () {
expect(CloudflareDomainConnect::splitHostname('example.com'))
->toBe(['domain' => 'example.com', 'host' => ''])
->and(CloudflareDomainConnect::splitHostname('app.example.com'))
->toBe(['domain' => 'example.com', 'host' => 'app'])
->and(CloudflareDomainConnect::splitHostname('https://api.staging.example.com/path'))
->toBe(['domain' => 'example.com', 'host' => 'api.staging'])
->and(CloudflareDomainConnect::splitHostname('app.example.co.uk'))
->toBe(['domain' => 'example.co.uk', 'host' => 'app'])
->and(CloudflareDomainConnect::splitHostname('api.staging.example.com.au'))
->toBe(['domain' => 'example.com.au', 'host' => 'api.staging']);
});
it('rejects invalid domains and ips', function () {
$connect = new CloudflareDomainConnect;
expect(fn () => $connect->buildHostingApplyUrl(domain: 'not-a-domain', ip: '203.0.113.10'))
->toThrow(InvalidArgumentException::class)
->and(fn () => $connect->buildHostingApplyUrl(domain: 'example.com', ip: 'not-an-ip'))
->toThrow(InvalidArgumentException::class);
});
+69
View File
@@ -0,0 +1,69 @@
<?php
use Illuminate\Support\Facades\Blade;
it('uses the large modal treatment for the compose editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/stack-form.blade.php'));
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
expect($view)
->toContain('title="Docker Compose"')
->toContain(':isLarge="true"')
->toContain('<x-slot:headerActions>')
->toContain("\$dispatch('compose-preview-toggle')")
->toContain("\$dispatch('compose-save')")
->toContain('@compose-save-finished.window="saving = false"')
->toContain('@compose-validate-finished.window="validating = false"')
->toContain('@click="validating = true; $dispatch(\'compose-validate\')"')
->toContain('x-bind:disabled="validating"')
->toContain('<x-loading-on-button x-show="validating" x-cloak />')
->toContain('<x-loading-on-button x-show="saving" x-cloak />')
->toContain('x-bind:disabled="saving"')
->not->toContain('name="refresh"')
->not->toContain("saving ? 'Saving...' : 'Save changes'")
->not->toContain(' :disabled="saving"')
->toContain('Preview generated Compose')
->toContain('Back to source Compose')
->toContain('Save changes')
->toContain('isHighlighted');
expect($modal)->toContain('lg:w-[95vw]! lg:max-w-7xl!');
});
it('renders the compose editor with clear guidance settings and actions', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/edit-compose.blade.php'));
expect($view)
->toContain('<x-callout type="info" title="Volume names">')
->not->toContain('View the final names')
->toContain('Use plain-text editor')
->toContain('min-h-[24rem]')
->toContain('@compose-preview-toggle.window')
->toContain('@compose-save.window')
->toContain('@compose-validate.window="$wire.validateCompose().finally(() => $dispatch(\'compose-validate-finished\'))"')
->not->toContain("finally(() => \$dispatch('compose-save-finished'))")
->not->toContain('sticky bottom-0')
->not->toContain('Cancel')
->not->toContain('Show Normal Textarea')
->not->toContain('Show Deployable Compose');
});
it('keeps the save button loading until the parent compose save finishes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/StackForm.php'));
expect($component)
->toContain('public function saveCompose($raw)')
->toContain("\$this->dispatch('compose-save-finished')");
});
it('does not show a saving notification for compose changes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/EditCompose.php'));
expect($component)->not->toContain("\$this->dispatch('info', 'Saving new docker compose...')");
});
it('keeps the saving state as an Alpine button binding', function () {
$html = Blade::render('<x-forms.button x-bind:disabled="saving">Save changes</x-forms.button>');
expect($html)->toContain('x-bind:disabled="saving"');
});
+112
View File
@@ -2,6 +2,11 @@
use App\Models\Application;
use App\Models\ApplicationPreview;
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;
uses(RefreshDatabase::class);
@@ -57,6 +62,52 @@ it('populates fqdn with multiple domains from multiple services', function () {
expect($preview->fqdn)->toContain('api.example.com');
});
it('preserves distinct services whose normalized names collide when generating preview domains', function () {
$dockerCompose = <<<'YAML'
services:
api-test:
image: nginx:alpine
api.test:
image: nginx:alpine
YAML;
$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 = StandaloneDocker::query()->where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'docker_compose_raw' => $dockerCompose,
'docker_compose_domains' => json_encode([
'api-test' => ['domain' => 'https://hyphen.example.com'],
'api.test' => ['domain' => 'https://dot.example.com'],
]),
]);
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 17,
'pull_request_html_url' => 'https://github.com/example/repo/pull/17',
'docker_compose_domains' => $application->docker_compose_domains,
]);
$preview->generate_preview_fqdn_compose();
$preview->refresh();
$domains = json_decode($preview->docker_compose_domains, true);
expect($domains)->toHaveKeys(['api-test', 'api.test'])
->and($domains)->toHaveCount(2)
->and($domains['api-test']['domain'])->toContain('hyphen.example.com')
->and($domains['api.test']['domain'])->toContain('dot.example.com');
});
it('sets fqdn to null when no domains are configured', function () {
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
@@ -78,3 +129,64 @@ it('sets fqdn to null when no domains are configured', function () {
expect($preview->fqdn)->toBeNull();
});
it('collapses dashed and underscore twin domain keys into one preview service', function () {
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_domains' => json_encode([
'web-api' => ['domain' => 'https://web-api.example.com'],
'web_api' => ['domain' => ''],
]),
]);
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 58,
'pull_request_html_url' => 'https://github.com/example/repo/pull/58',
// Existing dual-key preview state from older Coolify versions / mixed write paths.
'docker_compose_domains' => json_encode([
'web_api' => ['domain' => 'https://old-preview.example.com'],
'web-api' => ['domain' => ''],
]),
]);
$preview->generate_preview_fqdn_compose();
$preview->refresh();
$domains = json_decode($preview->docker_compose_domains, true);
expect($domains)->toHaveKey('web-api')
->and($domains)->not->toHaveKey('web_api')
->and($domains)->toHaveCount(1)
->and($domains['web-api']['domain'])->toContain('web-api.example.com')
->and($domains['web-api']['domain'])->toContain('58')
->and($preview->fqdn)->toContain('web-api.example.com');
});
it('reads legacy underscore application domains when generating previews for dashed services', function () {
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_domains' => json_encode([
'web_api' => ['domain' => 'https://legacy.example.com'],
]),
]);
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 12,
'pull_request_html_url' => 'https://github.com/example/repo/pull/12',
'docker_compose_domains' => json_encode([
'web-api' => ['domain' => ''],
]),
]);
$preview->generate_preview_fqdn_compose();
$preview->refresh();
$domains = json_decode($preview->docker_compose_domains, true);
expect($domains)->toHaveKey('web-api')
->and($domains)->not->toHaveKey('web_api')
->and($domains['web-api']['domain'])->toContain('legacy.example.com')
->and($preview->fqdn)->toContain('legacy.example.com');
});
+82
View File
@@ -0,0 +1,82 @@
<?php
use Illuminate\Contracts\Process\ProcessResult;
use Illuminate\Support\Facades\Process;
it('matches comma separated container roles and lets all override every service', function (string $roles, string $serviceRole) {
$result = runContainerRoleHelper($roles, $serviceRole);
expect($result->successful())->toBeTrue();
})->with([
['worker,flux', 'worker'],
['worker,flux', 'flux'],
['web, all', 'flux'],
['flux,all,worker', 'worker'],
['horizon,scheduler,nightwatch,flux', 'horizon'],
['horizon,scheduler,nightwatch,flux', 'scheduler'],
['horizon,scheduler,nightwatch,flux', 'nightwatch'],
]);
it('rejects services missing from the comma separated container roles', function () {
$result = runContainerRoleHelper('web,flux', 'worker');
expect($result->failed())->toBeTrue();
});
it('falls back to the container role from the local env file', function () {
$directory = sys_get_temp_dir().'/coolify-container-role-'.bin2hex(random_bytes(4));
mkdir($directory);
file_put_contents($directory.'/.env', 'COOLIFY_CONTAINER_ROLE=worker,flux'.PHP_EOL);
$result = runContainerRoleHelper('', 'flux', $directory);
expect($result->successful())->toBeTrue();
});
it('keeps container roles out of the production image', function () {
expect(base_path('docker/production/etc/s6-overlay/scripts/container-role'))->not->toBeFile();
foreach (['horizon', 'nightwatch-agent', 'scheduler-worker'] as $service) {
$runScript = file_get_contents(base_path("docker/production/etc/s6-overlay/s6-rc.d/{$service}/run"));
expect($runScript)
->not->toContain('container-role')
->not->toContain('COOLIFY_CONTAINER_ROLE');
}
});
function runContainerRoleHelper(string $roles, string $serviceRole, ?string $workingDirectory = null): ProcessResult
{
$script = base_path('docker/development/etc/s6-overlay/scripts/container-role');
$command = sprintf(
'. %s && coolify_container_has_role %s',
escapeshellarg($script),
escapeshellarg($serviceRole),
);
return Process::path($workingDirectory ?? base_path())
->env(['COOLIFY_CONTAINER_ROLE' => $roles])
->run($command);
}
it('does not register a separate v5 flux status listener service', function () {
foreach (['development', 'production'] as $environment) {
$base = base_path("docker/{$environment}/etc/s6-overlay/s6-rc.d");
expect(file_exists("{$base}/v5-flux-status-listener"))->toBeFalse()
->and(file_exists("{$base}/user/contents.d/v5-flux-status-listener"))->toBeFalse();
}
});
it('configures development flux to publish v5 resource statuses to laravel over local http by default', function () {
$runScript = file_get_contents(base_path('docker/development/etc/s6-overlay/s6-rc.d/flux/run'));
expect($runScript)
->toContain('COOLIFY_FLUX_LARAVEL_API_URL')
->toContain('http://127.0.0.1:8080')
->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN')
->not->toContain('APP_KEY')
->not->toContain("grep -E '^APP_KEY=' .env")
->not->toContain('COOLIFY_FLUX_REDIS_URL')
->not->toContain('COOLIFY_FLUX_RESOURCE_STATUS_CHANNEL');
});
@@ -3,11 +3,14 @@
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\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -15,124 +18,160 @@ 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]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::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]);
});
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);
it('creates a standalone database backup without S3 and opens its configuration', function () {
$database = StandalonePostgresql::create([
'name' => 'postgres',
'image' => 'postgres:16-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id);
->assertDontSee('Save to S3')
->set('frequency', 'daily')
->call('submit');
$s3->update(['team_id' => Team::factory()->create()->id]);
$backup = ScheduledDatabaseBackup::query()->sole();
$component
->call('submit')
->assertDispatched('error');
$component->assertRedirectToRoute('project.database.backup.execution', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
]);
expect(ScheduledDatabaseBackup::count())->toBe(0);
expect($backup->save_s3)->toBeFalsy()
->and($backup->s3_storage_id)->toBeNull();
});
it('rejects an S3 storage that becomes unusable after the component is mounted', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
it('creates a service database backup without S3 and opens its configuration', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$database = ServiceDatabase::create([
'service_id' => $service->id,
'name' => 'postgres',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id);
->assertDontSee('Save to S3')
->set('frequency', 'daily')
->call('submit');
$s3->update(['is_usable' => false]);
$backup = ScheduledDatabaseBackup::query()->sole();
$component
$component->assertRedirectToRoute('project.service.database.backup.show', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
]);
expect($backup->save_s3)->toBeFalsy()
->and($backup->s3_storage_id)->toBeNull();
});
it('selects a service database when creating a backup from the unified backups page', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
ServiceDatabase::create([
'service_id' => $service->id,
'name' => 'primary',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$analytics = ServiceDatabase::create([
'service_id' => $service->id,
'name' => 'analytics',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
Livewire::test(CreateScheduledBackup::class, ['service' => $service])
->assertSee('Database')
->assertSee('analytics')
->set('selectedDatabaseUuid', $analytics->uuid)
->set('frequency', 'daily')
->call('submit');
expect(ScheduledDatabaseBackup::query()->sole()->database->is($analytics))->toBeTrue();
});
it('creates a clickhouse backup for its configured database', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneClickhouse::create([
'name' => 'clickhouse-scheduled-backup',
'clickhouse_admin_user' => 'default',
'clickhouse_admin_password' => 'password',
'clickhouse_db' => 'analytics',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', 'daily')
->call('submit');
$backup = ScheduledDatabaseBackup::firstOrFail();
$component->assertRedirectToRoute('project.database.backup.execution', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
]);
expect($backup->database_type)->toBe(StandaloneClickhouse::class)
->and($backup->databases_to_backup)->toBe('analytics');
});
it('rejects scheduled backups for unsupported database types', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneRedis::create([
'name' => 'redis-without-backups',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', 'daily')
->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);
});
+177
View File
@@ -0,0 +1,177 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Dashboard\ActiveDeployments;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
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]);
$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' => 'Dashboard App',
'status' => 'running',
]);
});
function createDashboardDeployment(array $overrides = []): ApplicationDeploymentQueue
{
return ApplicationDeploymentQueue::create(array_merge([
'application_id' => test()->application->id,
'application_name' => test()->application->name,
'deployment_uuid' => 'deploy-'.fake()->unique()->uuid(),
'deployment_url' => '/deployments/'.fake()->uuid(),
'server_id' => test()->server->id,
'server_name' => test()->server->name,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'pull_request_id' => 0,
], $overrides));
}
it('hides the deployments section when there is nothing to show', function () {
Livewire::test(ActiveDeployments::class)
->assertDontSee('Active and recent deployment activity')
->assertDontSee('Running or queued right now')
->assertDontSee('Latest completed deployments')
->assertDontSee('No active deployments')
->assertDontSee('No recent deployments');
});
it('shows only the active section when there are active deployments', function () {
createDashboardDeployment([
'application_name' => 'Running Now',
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
Livewire::test(ActiveDeployments::class)
->assertSee('Deployments')
->assertSee('Running or queued right now')
->assertSee('Running Now')
->assertDontSee('Latest completed deployments')
->assertDontSee('No active deployments')
->assertDontSee('No recent deployments');
});
it('shows only the recent section when there are recent deployments', function () {
createDashboardDeployment([
'application_name' => 'Finished Earlier',
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
Livewire::test(ActiveDeployments::class)
->assertSee('Deployments')
->assertSee('Latest completed deployments')
->assertSee('Finished Earlier')
->assertDontSee('Running or queued right now')
->assertDontSee('No active deployments')
->assertDontSee('No recent deployments');
});
it('shows up to five active deployments above recent ones', function () {
foreach (range(1, 6) as $index) {
createDashboardDeployment([
'application_name' => "Active App {$index}",
'status' => $index % 2 === 0
? ApplicationDeploymentStatus::QUEUED->value
: ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
}
foreach (range(1, 6) as $index) {
createDashboardDeployment([
'application_name' => "Recent App {$index}",
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
}
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('activeDeployments'))->toHaveCount(5)
->and($component->get('recentDeployments'))->toHaveCount(5);
$component
->assertSee('Active App')
->assertSee('Recent App')
->assertSee('Success')
->assertSee('In progress')
->assertSee('Queued')
->assertDontSee('Active App 6');
});
it('does not include active deployments in the recent list', function () {
createDashboardDeployment([
'application_name' => 'Running Now',
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
createDashboardDeployment([
'application_name' => 'Finished Earlier',
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
createDashboardDeployment([
'application_name' => 'Failed Earlier',
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('activeDeployments')->pluck('application_name')->all())
->toBe(['Running Now'])
->and($component->get('recentDeployments')->pluck('application_name')->all())
->toEqualCanonicalizing(['Finished Earlier', 'Failed Earlier']);
$component
->assertSee('Running Now')
->assertSee('Finished Earlier')
->assertSee('Failed Earlier')
->assertSee('Failed');
});
it('only includes deployments for servers owned by the current team', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
createDashboardDeployment([
'application_name' => 'Team Deployment',
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'application_name' => 'Other Team Deployment',
'deployment_uuid' => 'deploy-other-'.fake()->uuid(),
'deployment_url' => '/deployments/other',
'server_id' => $otherServer->id,
'server_name' => $otherServer->name,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'pull_request_id' => 0,
]);
Livewire::test(ActiveDeployments::class)
->assertSee('Team Deployment')
->assertDontSee('Other Team Deployment');
});
@@ -0,0 +1,19 @@
<?php
test('settings header navigation is replaced by the settings sidebar', function () {
$path = resource_path('views/components/dashboard/navbar.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain("['label' => 'General', 'route' => 'settings.index', 'active' => request()->routeIs('settings.*')]")
->not->toContain("['label' => 'Email', 'route' => 'settings.email'");
});
test('dashboard navbar keeps the original side-by-side tab and actions layout', function () {
$path = resource_path('views/components/dashboard/navbar.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between')
->toContain('flex min-w-0 w-full items-center gap-0.5 overflow-x-auto');
});
@@ -0,0 +1,108 @@
<?php
use App\Livewire\Dashboard;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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]);
});
it('shows an add private key button when no private keys exist', function () {
Livewire::test(Dashboard::class)
->assertSee('A private key is required')
->assertSee('Add an SSH private key before connecting your first server.')
->assertSee('Add private key')
->assertSeeHtml('button button-highlighted')
->assertSee(route('security.private-key.index'), false);
});
it('shows a new server button when private keys exist but servers do not', function () {
PrivateKey::factory()->create([
'team_id' => $this->team->id,
'name' => 'Deploy Key',
]);
Livewire::test(Dashboard::class)
->assertSee('No servers yet')
->assertSee('Connect infrastructure for your deployments.')
->assertSee('New server')
->assertSee(route('server.create'), false)
->assertDontSee('A private key is required')
->assertDontSee('Add private key');
});
it('does not show server empty state when servers exist', function () {
$privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'name' => 'Production Host',
]);
Livewire::test(Dashboard::class)
->assertSee('Production Host')
->assertDontSee('A private key is required')
->assertDontSee('No servers yet');
});
it('renders clickable server cards without a decorative arrow control', function () {
$privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'name' => 'Production Host',
]);
Livewire::test(Dashboard::class)
->assertSee('Production Host')
->assertSeeHtml('aria-label="Open Production Host"')
->assertSeeHtml(route('server.show', ['server_uuid' => $server->uuid]))
// Decorative arrow used z-10 over the full-card link and blocked clicks.
->assertDontSeeHtml('group-hover:bg-neutral-100 group-hover:text-black dark:text-fg-faint dark:group-hover:bg-white/[0.06]');
});
it('keeps resource grid cards free of decorative open chevrons', function () {
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($view)
->toContain('absolute inset-0 rounded-xl')
->toContain('aria-label="`Open ${item.name}`"')
// Decorative card chevrons used a dedicated size-7 open control.
->not->toContain('flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg');
});
it('hides the add private key button for members without create permission', function () {
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(Dashboard::class)
->assertSee('A private key is required')
->assertDontSee('Add private key');
});
@@ -0,0 +1,19 @@
<?php
it('uses a database list editor instead of a comma-separated text field', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
expect($view)
->toContain('<div class="grid w-full gap-4">')
->toContain("['value' => true, 'label' => 'All databases']")
->toContain("['value' => false, 'label' => 'Specific databases']")
->toContain("value: @entangle('databasesToBackup').live")
->toContain('class="chip-input"')
->toContain('class="chip font-mono"')
->toContain('class="chip-remove"')
->toContain('addDatabase()')
->toContain('removeDatabase(index)')
->toContain('Type a database and press Enter');
expect(substr_count($view, '<x-forms.input label="Databases to back up"'))->toBe(1);
});
@@ -0,0 +1,34 @@
<?php
it('matches the storage backup overview layout', function () {
$index = file_get_contents(resource_path('views/livewire/project/database/backup/index.blade.php'));
$schedules = file_get_contents(resource_path('views/livewire/project/database/scheduled-backups.blade.php'));
$component = file_get_contents(app_path('Livewire/Project/Database/Backup/Index.php'));
expect($index)
->toContain('title="Database backups"')
->toContain('Schedules')
->toContain('Enabled')
->toContain('Total executions')
->toContain('application-settings-form flex min-w-0 flex-col gap-6')
->and($schedules)
->toContain('placeholder="Search backups"')
->toContain('data-table overflow-hidden rounded-xl border')
->toContain('<span class="block truncate text-[12px] font-semibold')
->toContain("route('project.database.backup.execution'")
->toContain("route('project.service.database.backup.show'")
->toContain("route('project.database.backup.executions'")
->toContain("route('project.service.database.backup.executions'")
->toContain('<a wire:navigate href="{{ $backupExecutionsRoute }}"')
->toContain('<a class="button" wire:navigate href="{{ $backupRoute }}">Manage</a>')
->and($component)->toContain("withCount('executions')");
});
it('returns from database backup settings to the database', function () {
$sidebar = file_get_contents(resource_path('views/components/backup-sidebar.blade.php'));
expect($sidebar)
->toContain("'back' => 'project.database.configuration'")
->toContain("'Back to database'")
->toContain("except('backup_uuid')");
});
@@ -0,0 +1,18 @@
<?php
it('keeps editable standalone database storage actions in the table action column', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/Storages/All.php'));
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect($component)
->toContain('$this->showActionsColumn = $this->canUpdate;')
->toContain('$this->showBackupAction = $this->resource instanceof Application')
->toContain('if (! $this->showBackupAction)')
->and($view)
->toContain('@if ($showActionsColumn)')
->toContain('@if ($showBackupAction)')
->toContain('title="New Scheduled Backup"')
->toContain('<livewire:project.database.create-scheduled-backup :database="$resource"')
->toContain('aria-label="Configure backup"')
->toContain('<x-reicon name="database" class="size-4" />');
});
@@ -0,0 +1,28 @@
<?php
it('shows a loading indicator while database public access is changing', function () {
$databaseTypes = [
'clickhouse',
'dragonfly',
'keydb',
'mariadb',
'mongodb',
'mysql',
'postgresql',
'redis',
];
foreach ($databaseTypes as $databaseType) {
$generalSettings = file_get_contents(resource_path("views/livewire/project/database/{$databaseType}/general.blade.php"));
expect($generalSettings)
->toContain('<x-table.loading target="instantSave" text="Updating public access..." />')
->toContain("'label' => blank(\$publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy'")
->toContain("'disabled' => blank(\$publicPort)")
->toContain('wire:key="public-access-{{ $publicPort ?: \'unset\' }}"')
->not->toContain('x-data="{ publicPort:')
->not->toContain('x-effect="options[1].disabled')
->not->toContain('id="publicPort" x-on:input=')
->not->toContain('id="publicPort" live');
}
});
@@ -0,0 +1,10 @@
<?php
test('database restore output opens in the centered process dialog', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
expect($view)
->toContain('<x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl">')
->toContain('<livewire:activity-monitor wire:key="database-restore-{{ $resourceUuid }}" header="Logs" fullHeight />')
->not->toContain('<x-slide-over @databaserestore.window="slideOverOpen = true"');
});
@@ -230,6 +230,34 @@ it('reloads the mysql status-info model when refresh is called so ssl controls f
->assertSee('Database should be stopped to change this settings.');
});
it('shows ssl mode even when ssl is disabled, as a disabled control', 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' => 'ssl-disabled-mysql',
'image' => 'mysql:8',
'mysql_root_password' => 'password',
'mysql_user' => 'coolify',
'mysql_password' => 'password',
'mysql_database' => 'coolify',
'status' => 'exited:unhealthy',
'enable_ssl' => false,
'ssl_mode' => 'REQUIRED',
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
Livewire::test(MysqlStatusInfo::class, ['database' => $database])
->assertSet('enableSsl', false)
->assertSee('SSL mode')
->assertSeeHtml('id="sslMode-trigger"');
});
it('does not clobber server form text inputs when sentinel restarts', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
+11
View File
@@ -0,0 +1,11 @@
<?php
test('new users default to dark mode', function () {
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
expect($layout)
->toContain('<html data-theme="dark"')
->toContain("const t = localStorage.theme || 'dark';")
->toContain("localStorage.theme = 'dark';")
->toContain('<meta name="theme-color" content="#101010" id="theme-color-meta" />');
});
@@ -0,0 +1,90 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Project\Application\Deployment\Index;
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;
uses(RefreshDatabase::class);
it('moves deployment history pagination by one page per action', function () {
expect((new ReflectionMethod(Index::class, 'nextPage'))->getNumberOfParameters())->toBe(0)
->and((new ReflectionMethod(Index::class, 'previousPage'))->getNumberOfParameters())->toBe(0);
});
it('filters deployment history by server', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$firstServer = Server::factory()->create(['team_id' => $team->id, 'name' => 'Primary']);
$secondServer = Server::factory()->create(['team_id' => $team->id, 'name' => 'Secondary']);
$destination = StandaloneDocker::query()->where('server_id', $firstServer->id)->firstOrFail();
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
foreach ([$firstServer, $secondServer] as $server) {
ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => "deployment-{$server->id}",
'server_id' => $server->id,
'server_name' => $server->name,
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
}
ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => 'webhook-deployment',
'server_id' => $secondServer->id,
'server_name' => $secondServer->name,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'is_webhook' => true,
]);
$result = $application->deployments(filters: [
'status:finished',
'source:manual',
'source:webhook',
"server:{$secondServer->id}",
]);
expect($result['count'])->toBe(2)
->and($result['deployments']->pluck('server_id')->unique()->sole())->toBe($secondServer->id);
});
it('always shows source filters and includes server filters', function () {
$component = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$filterComponent = file_get_contents(resource_path('views/components/table/filter.blade.php'));
$loadingComponent = file_get_contents(resource_path('views/components/table/loading.blade.php'));
expect($component)
->toContain('public array $serverFilterOptions = [];')
->toContain('public array $deploymentFilters = [];')
->toContain('public function toggleDeploymentFilter(string $filter): void')
->toContain("'value' => \"server:{\$serverId}\"")
->and($view)
->toContain('@if (count($sourceFilterOptions) > 0)')
->toContain('count($serverFilterOptions) > 0')
->toContain('>Server</span>')
->toContain('<x-table.filter')
->toContain("wire:click=\"toggleDeploymentFilter('{{ \$option['value'] }}')\"")
->toContain("in_array(\$option['value'], \$deploymentFilters, true)")
->toContain('<x-table.loading id="deployment-table-filter-loading"')
->not->toContain('class="size-3.5" wire:loading.remove')
->not->toContain('<span>All deployments</span>')
->and($filterComponent)
->toContain('aria-multiselectable="true"')
->toContain('Reset filters')
->and($loadingComponent)
->toContain('wire:loading.flex');
});
@@ -0,0 +1,21 @@
<?php
test('active deployment log controls use a coollabs background and white icon', function () {
$styles = file_get_contents(resource_path('css/app.css'));
expect($styles)
->toMatch('/\.logs-viewer-btn-active\s*\{[^}]*background:\s*var\(--color-coollabs\);[^}]*color:\s*#fff;/s')
->toMatch('/\.dark \.logs-viewer-btn-active\s*\{[^}]*color:\s*#fff;/s')
->not->toMatch('/\.logs-viewer-btn-active\s*\{[^}]*var\(--color-warning\)/s');
});
test('deployment logs use light surfaces in light mode and dark surfaces in dark mode', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
expect($view)
->toContain('bg-white text-neutral-800')
->toContain('dark:bg-[#0d0d0d] dark:text-neutral-100')
->toContain('border-neutral-200 shadow-sm dark:border-neutral-800')
->toContain('border-neutral-200! bg-white!')
->toContain('dark:border-white/[0.08]! dark:bg-white/[0.05]!');
});
+183 -4
View File
@@ -69,11 +69,190 @@ it('renders deployment logs in a full-height layout', function () {
$response->assertSuccessful();
$response->assertSee('rolling update started');
$response->assertSee('flex h-[calc(100vh-10rem)] min-h-40 flex-col overflow-hidden', false);
$response->assertSee('logs-viewer', false);
$response->assertSee('logs-viewer-toolbar', false);
$response->assertSee('logs-viewer-search', false);
$response->assertSee('logs-viewer-actions', false);
$response->assertSee('logs-viewer-viewport', false);
$response->assertSee('logs-viewer-line', false);
$response->assertSee('h-[calc(100dvh-7.5rem)]', 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]');
});
it('uses a mobile-friendly stacked logs toolbar markup', function () {
$deploymentView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($deploymentView)
->toContain('logs-viewer-toolbar')
->toContain('logs-viewer-toolbar-controls')
->toContain('logs-viewer-primary')
->toContain('logs-viewer-meta')
->toContain('logs-viewer-end')
->toContain('logs-viewer-status-badge')
->toContain('logs-viewer-line')
->toContain('logs-viewer-timestamp')
->toContain('logs-viewer-line-text')
->toContain('livewire:project.application.deployment-navbar')
->and($sharedLogsView)
->toContain('logs-viewer-toolbar')
->toContain('logs-viewer-toolbar-controls')
->toContain('logs-viewer-meta')
->toContain('logs-viewer-lines')
->toContain('logs-viewer-actions')
->toContain('logs-viewer-line')
->toContain('pl-8!')
->toContain('z-10 size-3.5')
->and($deploymentView)
->toContain('pl-8!')
->toContain('z-10 size-3.5')
->and($appCss)
->toContain('.logs-viewer-toolbar')
->toContain('.logs-viewer-end')
->toContain('.logs-viewer-status-badge')
->toContain('.logs-viewer-lines')
->toContain('.logs-viewer-actions')
->toContain('.logs-viewer-deployment-actions')
->toContain('.logs-settings-section')
->toContain('flex-direction: column')
->toContain('@media (min-width: 640px)');
$primaryGroup = str($deploymentView)
->after('class="logs-viewer-primary"')
->before('class="logs-viewer-end"')
->toString();
$endGroup = str($deploymentView)
->after('class="logs-viewer-end"')
->before('logs-viewer-viewport')
->toString();
expect($primaryGroup)
->toContain('logs-viewer-status-badge')
->not->toContain('livewire:project.application.deployment-navbar');
$timestampPos = strpos($primaryGroup, 'Toggle Timestamps');
$followPos = strpos($primaryGroup, 'Follow Logs');
$debugPos = strpos($primaryGroup, 'wire:click="toggleDebug"');
$fullscreenPos = strpos($primaryGroup, 'title="Fullscreen"');
$copyPos = strpos($primaryGroup, 'title="Copy Logs"');
$downloadPos = strpos($primaryGroup, 'title="Download Logs"');
$badgePos = strpos($primaryGroup, 'logs-viewer-status-badge');
expect([$timestampPos, $followPos, $debugPos, $fullscreenPos, $copyPos, $downloadPos, $badgePos])
->not->toContain(false)
->and($timestampPos)->toBeLessThan($followPos)
->and($followPos)->toBeLessThan($debugPos)
->and($debugPos)->toBeLessThan($fullscreenPos)
->and($fullscreenPos)->toBeLessThan($copyPos)
->and($copyPos)->toBeLessThan($downloadPos)
->and($downloadPos)->toBeLessThan($badgePos)
->and($endGroup)->toContain('livewire:project.application.deployment-navbar')
->toContain('Find in logs');
$runtimeActionsPos = strpos($sharedLogsView, 'class="logs-viewer-actions"');
$runtimeEndPos = strpos($sharedLogsView, 'class="logs-viewer-end runtime-logs-viewer-end"');
$runtimeLinesPos = strpos($sharedLogsView, 'class="logs-viewer-lines"');
$runtimeSearchPos = strpos($sharedLogsView, 'placeholder="Find in logs"');
expect([$runtimeActionsPos, $runtimeEndPos, $runtimeLinesPos, $runtimeSearchPos])
->not->toContain(false)
->and($runtimeActionsPos)->toBeLessThan($runtimeEndPos)
->and($runtimeEndPos)->toBeLessThan($runtimeLinesPos)
->and($runtimeLinesPos)->toBeLessThan($runtimeSearchPos)
->and($appCss)->toContain('.runtime-logs-viewer-end')
->toContain('.dark .runtime-log-icon-button-active')
->toContain('background: var(--color-coollabs)')
->toContain(".dark .runtime-log-icon-button {\n color: var(--color-fg-faint);")
->toContain(".dark .runtime-log-icon-button-active {\n background: var(--color-coollabs);\n color: #fff;")
->toContain(".runtime-log-toolbar {\n position: relative;\n z-index: 20;")
->and($sharedLogsView)
->toContain('runtime-log-icon-button order-1')
->toContain('runtime-log-icon-button order-2')
->toContain('runtime-log-icon-button order-5')
->toContain('runtime-log-icon-button order-6')
->toContain('relative order-7 shrink-0');
});
it('keeps deployment history fields and the log status badge accessible on mobile', function () {
$deploymentIndexView = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($deploymentIndexView)
->toContain('deployment-table-scroll')
->and($appCss)
->toContain(".deployment-table-scroll {\n overflow-x: auto;")
->toContain(".deployment-table-grid {\n min-width: 59rem;")
->not->toContain('.deployment-table-grid > :nth-child')
->toContain(".logs-viewer-primary .logs-viewer-actions {\n width: auto;\n flex: 1 1 auto;");
});
it('places cancel deployment controls inside the deployment logs toolbar', function () {
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'deploy-cancel-toolbar-test',
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'logs' => json_encode([
[
'command' => null,
'output' => 'building image',
'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('Cancel deployment');
$response->assertSee('logs-viewer-end', false);
$response->assertSee('logs-viewer-deployment-actions', false);
$response->assertSee('logs-viewer-cancel-btn', false);
$content = $response->getContent();
$toolbarPos = strpos($content, 'logs-viewer-toolbar');
$endPos = strpos($content, 'logs-viewer-end');
$viewportPos = strpos($content, 'logs-viewer-viewport');
expect($toolbarPos)->not->toBeFalse()
->and($endPos)->not->toBeFalse()
->and($viewportPos)->not->toBeFalse()
->and($endPos)->toBeGreaterThan($toolbarPos)
->and($endPos)->toBeLessThan($viewportPos);
// Within the right-side end group: cancel controls, then log search.
$endGroup = substr($content, $endPos, $viewportPos - $endPos);
$cancelPos = strpos($endGroup, 'Cancel deployment');
$searchPos = strpos($endGroup, 'Find in logs');
expect($cancelPos)->not->toBeFalse()
->and($searchPos)->not->toBeFalse()
->and($cancelPos)->toBeLessThan($searchPos)
->and($content)->toContain('In progress');
});
it('uses the shared mobile logs layout for proxy and sentinel log pages', function () {
$proxyLogsView = file_get_contents(resource_path('views/livewire/server/proxy/logs.blade.php'));
$sentinelLogsView = file_get_contents(resource_path('views/livewire/server/sentinel/logs.blade.php'));
expect($proxyLogsView)
->toContain('logs-settings-section')
->toContain('logs-section-status-badge')
->toContain('livewire:project.shared.get-logs')
->and($sentinelLogsView)
->toContain('logs-settings-section')
->toContain('logs-section-status-badge')
->toContain('livewire:project.shared.get-logs');
});
@@ -0,0 +1,88 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\DeploymentsIndicator;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
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]);
$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' => 'Indicator App',
'status' => 'running',
]);
ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'application_name' => $this->application->name,
'deployment_uuid' => 'deploy-indicator-'.fake()->uuid(),
'deployment_url' => '/deployments/indicator',
'server_id' => $this->server->id,
'server_name' => $this->server->name,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'pull_request_id' => 0,
]);
});
it('hides the floating deployments indicator on the dashboard', function () {
$this->get(route('dashboard'))
->assertSuccessful()
->assertDontSee('1 deployment', false)
->assertDontSee('aria-label="Active deployments"', false);
});
it('shows the floating deployments indicator on non-dashboard pages', function () {
$this->get(route('project.index'))
->assertSuccessful()
->assertSee('1 deployment', false)
->assertSee('aria-label="Active deployments"', false);
});
it('keeps the indicator hidden across polls after mounting on the dashboard', function () {
$component = Livewire::test(DeploymentsIndicator::class)
->set('shouldShow', false)
->assertDontSee('1 deployment');
// Polls call refresh methods without remounting; visibility must stay sticky.
$component
->call('$refresh')
->assertSet('shouldShow', false)
->assertDontSee('1 deployment');
});
it('updates visibility from the browser path after navigation', function () {
Livewire::test(DeploymentsIndicator::class)
->assertSet('shouldShow', true)
->call('updateShouldShowFromPath', '/')
->assertSet('shouldShow', false)
->assertDontSee('1 deployment')
->call('updateShouldShowFromPath', '/projects')
->assertSet('shouldShow', true)
->assertSee('1 deployment');
});
@@ -16,3 +16,26 @@ it('positions the deployments indicator from the sidebar collapsed state', funct
expect(strpos($layoutView, '<div x-data="{'))
->toBeLessThan(strpos($layoutView, '<livewire:deployments-indicator />'));
});
it('uses the redesigned elevated surface and status badge patterns', function () {
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
expect($indicatorView)
->toContain('var(--coollabs-elevated)')
->toContain('var(--coollabs-line)')
->toContain('var(--shadow-modal)')
->toContain('var(--coollabs-hairline)')
->toContain('<x-status-badge')
->toContain('name="chevron-down"')
->toContain("'In progress'")
->toContain("'Queued'")
->toContain('dark:text-fg')
->toContain('dark:border-white/[0.08]')
->toContain('$this->shouldShow')
->toContain('updateShouldShowFromPath')
->toContain('livewire:navigated')
->not->toContain('dark:bg-coolgray-100')
->not->toContain('dark:bg-coolgray-200')
->not->toContain('text-gray-800')
->not->toContain("str_replace('_', ' ', \$deployment->status)");
});
@@ -0,0 +1,12 @@
<?php
it('returns after creating a destination and shows submit loading state', function () {
$component = file_get_contents(app_path('Livewire/Destination/New/Docker.php'));
$view = file_get_contents(resource_path('views/livewire/destination/new/docker.blade.php'));
expect($component)
->toContain("return redirectRoute(\$this, 'destination.show', [\$docker->uuid]);");
expect($view)
->toContain('wire:submit="submit"')
->toContain('wire:target="submit"');
});
@@ -0,0 +1,40 @@
<?php
it('uses sidebar navigation and breadcrumbs for destination details', function () {
$show = file_get_contents(resource_path('views/livewire/destination/show.blade.php'));
$resources = file_get_contents(resource_path('views/livewire/destination/resources.blade.php'));
$sidebar = file_get_contents(resource_path('views/livewire/destination/sidebar.blade.php'));
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
$breadcrumbs = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
expect($show)
->toContain(':mobileTitleOnly="true"')
->toContain("@include('livewire.destination.sidebar'");
expect($resources)
->toContain(':mobileTitleOnly="true"')
->toContain("@include('livewire.destination.sidebar'");
expect($sidebar)
->toContain("'label' => 'General'")
->toContain("'label' => 'Resources'")
->toContain("'label' => 'Danger Zone'")
->toContain('application-settings-navigation min-w-0 xl:self-start');
expect($navbar)
->toContain("request()->routeIs('destination.show', 'destination.resources', 'destination.danger')")
->not->toContain("['label' => 'Resources', 'route' => 'destination.resources'");
expect($breadcrumbs)
->toContain('x-breadcrumb-switcher')
->toContain('$currentDestination->name')
->toContain("route('destination.show'")
->toContain("? 'Docker' : 'Deprecated'");
expect($breadcrumbs)
->toContain('Pages')
->toContain('Projects')
->toContain('Environments')
->not->toContain('M5 12l5 5 9-11');
expect($show)
->toContain('destination-danger-section')
->toContain('title="Danger zone"')
->not->toContain("actions=['This will delete the selected destination/network.']");
expect(file_get_contents(base_path('routes/web.php')))
->toContain("->name('destination.danger')");
});
@@ -0,0 +1,60 @@
<?php
it('does not include coold dev tooling defaults in the development env example', function (string $variable) {
$developmentExample = file_get_contents(base_path('.env.development.example'));
expect($developmentExample)->not->toContain($variable.'=');
})->with([
'coold package version default' => 'COOLIFY_COOLD_VERSION',
'flux package version default' => 'COOLIFY_FLUX_VERSION',
'corrosion package version default' => 'COOLIFY_CORROSION_VERSION',
'coold VM count default' => 'COOLIFY_COOLD_VM_COUNT',
'coold VM flux URL default' => 'COOLIFY_COOLD_VM_FLUX_URL',
'coold VM WireGuard IP 1 default' => 'COOLIFY_COOLD_VM_WG_IP_1',
'coold VM WireGuard IP 2 default' => 'COOLIFY_COOLD_VM_WG_IP_2',
'coold VM WireGuard port 1 default' => 'COOLIFY_COOLD_VM_WG_PORT_1',
'coold VM WireGuard port 2 default' => 'COOLIFY_COOLD_VM_WG_PORT_2',
'coold VM enabled default' => 'COOLIFY_COOLD_VM_ENABLED',
'coold VM stop on down default' => 'COOLIFY_COOLD_VM_STOP_ON_DOWN',
'dev follow logs default' => 'COOLIFY_DEV_FOLLOW_LOGS',
]);
it('defaults coold dev VM settings in Laravel config', function () {
expect(config('coold.coolify_cli_bin'))->toBe('/usr/local/bin/coolify')
->and(config('coold.coold_version'))->toBe('nightly')
->and(config('coold.corrosion_version'))->toBe('v1.0.0')
->and(config('coold.dev_ssh_user'))->toBe('coolify')
->and(config('coold.dev_ssh_user'))->toBe('coolify');
});
it('runs the v5 dev Lima seeder with the normal development database seeder', function () {
$databaseSeeder = file_get_contents(database_path('seeders/DatabaseSeeder.php'));
$developmentSeederBlock = str($databaseSeeder)->after("if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) {")->before(' }')->toString();
expect($developmentSeederBlock)->toContain('DevelopmentRailpackExamplesSeeder::class')
->and($developmentSeederBlock)->not->toContain('V5DevLimaSeeder::class');
});
it('disables Flux host binding by default in the Docker development environment', function () {
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
$runScript = file_get_contents(base_path('docker/development/etc/s6-overlay/s6-rc.d/flux/run'));
expect($compose)->toContain('COOLIFY_FLUX_REQUIRE_HOST_BINDING: "${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}"')
->and($runScript)->toContain('COOLIFY_FLUX_REQUIRE_HOST_BINDING="${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}"');
});
it('cache busts mutable nightly coold assets with resolved checksums', function () {
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
$dockerfile = file_get_contents(base_path('docker/development/Dockerfile'));
$devScript = file_get_contents(base_path('scripts/dev.sh'));
expect($compose)->toContain('COOLIFY_FLUX_CHECKSUM=${COOLIFY_FLUX_CHECKSUM:-unknown}')
->and($compose)->toContain('COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown}')
->and($dockerfile)->toContain('ARG COOLIFY_FLUX_CHECKSUM')
->and($dockerfile)->toContain('ARG COOLIFY_CLI_CHECKSUM')
->and($dockerfile)->toContain('Flux checksum: ${COOLIFY_FLUX_CHECKSUM}')
->and($dockerfile)->toContain('Coolify CLI checksum: ${COOLIFY_CLI_CHECKSUM}')
->and($devScript)->toContain('resolve_coold_asset_checksum')
->and($devScript)->toContain('export COOLIFY_FLUX_CHECKSUM=')
->and($devScript)->toContain('export COOLIFY_CLI_CHECKSUM=');
});
@@ -0,0 +1,23 @@
<?php
use Symfony\Component\Process\Process;
it('documents example nginx connectivity commands', function () {
$process = new Process(['bash', base_path('scripts/dev.sh'), 'example-nginx', 'help'], base_path());
$process->run();
expect($process->isSuccessful())->toBeTrue()
->and($process->getOutput())->toContain('ping')
->and($process->getOutput())->not->toContain('firewall up')
->and($process->getOutput())->not->toContain('firewall down')
->and($process->getOutput())->not->toContain('firewall-up')
->and($process->getOutput())->not->toContain('firewall-down');
});
it('does not wire example nginx to removed firewall CLI commands', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->not->toContain('scripts/dev.sh firewall allow "$src" "$dst" tcp 80')
->and($script)->not->toContain('scripts/dev.sh firewall revoke "$src" "$dst" tcp 80');
});
@@ -0,0 +1,201 @@
<?php
it('does not expose removed dev firewall commands', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->not->toContain('coolify_firewall()')
->and($script)->not->toContain('scripts/dev.sh firewall')
->and($script)->not->toContain('firewall <command>')
->and($script)->not->toContain('/api/v1/firewall/allow')
->and($script)->not->toContain('firewall_api_for_each_vm');
});
it('installs the coolify CLI in both application container images', function (string $dockerfile) {
$contents = file_get_contents(base_path($dockerfile));
expect($contents)->toContain('ARG COOLIFY_CLI_VERSION=nightly')
->and($contents)->toContain('coolify-linux-musl-${COOLIFY_CLI_ARCH}.tar.gz')
->and($contents)->toContain('install -m 0755 /tmp/coolify /usr/local/bin/coolify');
})->with([
'development image' => 'docker/development/Dockerfile',
'production image' => 'docker/production/Dockerfile',
]);
it('runs the coolify CLI from the development application container', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain("printf '%s\\n' '/usr/local/bin/coolify'")
->and($script)->toContain('spin exec -T coolify "$(coolify_cli_bin)" init bootstrap')
->and($script)->toContain('spin exec -T coolify "$(coolify_cli_bin)" "$@"')
->and($script)->toContain('ensure_coolify_container_ssh_key')
->and($script)->not->toContain('.dev/bin/coolify')
->and($script)->not->toContain('coolify-${os}-${arch}.tar.gz');
});
it('syncs host-resolved Lima local names into the Coolify container hosts file', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('resolve_lima_dns_name()')
->and($script)->toContain('dscacheutil -q host -a name "$name"')
->and($script)->toContain('getent ahostsv4 "$name"')
->and($script)->toContain('sync_lima_hosts_into_coolify_container()')
->and($script)->toContain('spin exec -T -u root coolify sh -lc')
->and($script)->toContain('cat "$next" > /etc/hosts')
->and($script)->toContain('sync_lima_hosts_into_coolify_container')
->and($script)->toContain('if [ "$naked" = "true" ]; then');
});
it('does not require predefined UI node environment variables in the development app container', function () {
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
$config = file_get_contents(base_path('config/coold.php'));
expect($compose)->not->toContain('COOLIFY_CLI_NODES:')
->and($compose)->not->toContain('COOLIFY_CLI_SSH_CONFIG:')
->and($compose)->not->toContain('COOLIFY_CLI_WG_LISTEN_PORT_OVERRIDES:')
->and($compose)->not->toContain('COOLIFY_CLI_WG_ENDPOINT_OVERRIDES:')
->and($compose)->not->toContain('COOLIFY_CLI_TIMEOUT:')
->and($config)->not->toContain('cli_nodes')
->and($config)->not->toContain('COOLIFY_CLI_NODES');
});
it('supports a naked up mode that starts VMs and Spin but skips server bootstrap wiring', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('local naked=false')
->and($script)->toContain('--naked')
->and($script)->toContain('if [ "${#spin_args[@]}" -gt 0 ]; then')
->and($script)->toContain('spin up -d "${spin_args[@]}"')
->and($script)->toContain('spin up -d')
->and($script)->toContain('if [ "$naked" = "true" ]; then')
->and($script)->toContain('Skipping coolify bootstrap and Flux VM wiring')
->and($script)->toContain('coolify_bootstrap_with_retry')
->and($script)->toContain('--concurrency "$(coolify_bootstrap_concurrency)"')
->and($script)->toContain('--ssh-timeout "$(coolify_bootstrap_ssh_timeout)"')
->and($script)->toContain('--debug')
->and($script)->toContain('configure_flux_dev_for_vm "$index"')
->and($script)->toContain('sync_v5_dev_lima_servers');
});
it('retries dev coolify bootstrap because fresh Lima setup can complete across partial phases', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('coolify_bootstrap_with_retry()')
->and($script)->toContain('local attempts=5')
->and($script)->toContain('if coolify_bootstrap; then')
->and($script)->toContain('diagnose_coold_bootstrap_failure')
->and($script)->toContain('systemctl --failed --no-pager')
->and($script)->toContain('systemctl --no-pager --full status wg-quick@wg0 podman.socket corrosion coold')
->and($script)->toContain('fresh Lima hosts can finish setup after partial bootstrap phases');
});
it('seeds bootstrapped Lima VMs into v5 development server state', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
expect($script)->toContain('sync_v5_dev_lima_servers()')
->and($script)->toContain('COOLIFY_CLI_SSH_USER="$ssh_user"')
->and($script)->toContain('COOLIFY_CLI_SSH_USER="$(coolify_ssh_user)" spin up -d')
->and($script)->toContain('coold_vm_dns_name()')
->and($script)->toContain('$(coold_vm_dns_name "$index")')
->and($script)->toContain('v5:sync-dev-lima-servers')
->and($script)->toContain('--server="${instance}|$(coold_vm_dns_name "$index")|${ssh_user}|22|$(coold_vm_wg_ip "$index")"')
->and($compose)->toContain('COOLIFY_CLI_SSH_USER: "${COOLIFY_CLI_SSH_USER:-}"')
->and($script)->not->toContain('db:seed --class=V5DevLimaSeeder --force')
->and($script)->not->toContain('host.docker.internal|${ssh_user}');
});
it('uses the shared Lima template directly instead of generated per-VM YAML', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
expect($script)->toContain('coold_vm_dns_name()')
->and($script)->not->toContain('COOLIFY_COOLD_VM_SSH_PORT="$(coold_vm_ssh_port "$index")"')
->and($vmScript)->toContain('limactl start --tty=false --name="$INSTANCE" "$TEMPLATE"')
->and($vmScript)->not->toContain('GENERATED=')
->and($vmScript)->not->toContain('generate_yaml()')
->and($template)->not->toContain('{{COOLIFY_COOLD_VM_SSH_PORT}}');
});
it('sets the Lima guest hostname to the instance name for predictable mDNS', function () {
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
expect($vmScript)->toContain('ensure_mdns_hostname()')
->and($vmScript)->toContain('hostnamectl set-hostname "$INSTANCE"')
->and($vmScript)->toContain('systemctl restart avahi-daemon.service')
->and($vmScript)->toContain('ensure_mdns_hostname')
->and($template)->toContain('hostnamectl set-hostname "{{.Name}}"')
->and($template)->not->toContain('hostnamectl set-hostname myvm');
});
it('authorizes the seeded testing host key in dev Lima VMs', function () {
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
expect($template)->toContain('coolify_test_public_key=')
->and($template)->toContain('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68')
->and($template)->toContain('authorized_keys')
->and($template)->toContain('grep -qxF "$coolify_test_public_key"')
->and($template)->toContain('install_public_key root')
->and($template)->toContain('install_public_key coolify')
->and($template)->toContain('owner="$(stat -c \'%u:%g\' "$home")"')
->and($template)->toContain('hostnamectl set-hostname "{{.Name}}"')
->and($template)->not->toContain('hostnamectl set-hostname myvm')
->and($template)->not->toContain('mode: user')
->and($template)->not->toContain('user="$(basename "$home")"');
});
it('hardcodes the naked Lima VM ssh port for bootstrap testing', function () {
$template = file_get_contents(base_path('.dev/lima/coolify-naked-test.yaml'));
expect($template)->toContain('ssh:')
->and($template)->toContain('localPort: 60003');
});
it('supports down cleanup as the preferred VM cleanup command', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('local cleanup=false')
->and($script)->toContain('--cleanup')
->and($script)->toContain('if [ "$cleanup" = "true" ]; then')
->and($script)->toContain('clean_vms')
->and($script)->toContain('down --cleanup')
->and($script)->toContain('clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)');
});
it('scripts a full fresh dev reset with bounded Lima startup retries', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
expect($script)->toContain('fresh()')
->and($script)->toContain('down --cleanup')
->and($script)->toContain('COOLIFY_DEV_FOLLOW_LOGS=false up')
->and($script)->toContain('php artisan migrate:fresh --seed --force')
->and($script)->toContain('sync_v5_dev_lima_servers')
->and($script)->toContain('recreate_naked_lima_vm')
->and($script)->toContain('COOLIFY_NAKED_VM_START_TIMEOUT')
->and($script)->toContain('limactl start --tty=false --name="$instance" "$config"')
->and($script)->toContain('cleanup_lima_instance_processes "$instance"')
->and($script)->toContain('did not become ready after ${attempts} attempts')
->and($script)->toContain('refresh_test_host_key')
->and($script)->toContain('coold_vm_up_with_retry()')
->and($script)->toContain('COOLIFY_COOLD_BOOTSTRAP_CONCURRENCY 1')
->and($script)->toContain('COOLIFY_COOLD_BOOTSTRAP_SSH_TIMEOUT 90s')
->and($script)->toContain('local attempts=2')
->and($script)->toContain('deleting and retrying with a fresh Lima instance')
->and($script)->toContain('cleanup_lima_instance_processes()')
->and($script)->toContain('kill_matching_processes()')
->and($script)->toContain('kill -9 $pids')
->and($script)->toContain('limactl hostagent .*${instance}')
->and($script)->toContain('ssh: .*/.lima/${instance}/ssh.sock')
->and($vmScript)->toContain('COOLIFY_COOLD_VM_START_TIMEOUT')
->and($vmScript)->toContain('lima_shell_timeout()')
->and($vmScript)->toContain('cleanup_lima_probe_processes()')
->and($vmScript)->toContain('cleanup_lima_hostagent_processes()')
->and($vmScript)->toContain('kill_matching_processes()')
->and($vmScript)->toContain('pkill -P "$pid"')
->and($vmScript)->toContain('lima_shell_timeout 5 true')
->and($vmScript)->toContain('Lima start timed out after ${START_TIMEOUT}s')
->and($vmScript)->toContain('Guest SSH timed out after ${START_TIMEOUT}s')
->and($vmScript)->toContain('Guest provisioning timed out after ${START_TIMEOUT}s');
});
@@ -17,7 +17,6 @@ use Database\Seeders\StandaloneDockerSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
uses(RefreshDatabase::class);
@@ -34,189 +33,79 @@ function seedRailpackExamplePrerequisites(): void
]);
}
function limaServers(): Collection
{
return collect(DevelopmentRailpackExamplesSeeder::LIMA_SERVERS);
}
it('can seed the railpack examples directly on a clean development database', function () {
config()->set('app.env', 'local');
$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(GitlabApp::query()->find(1))->not->toBeNull();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
expect(Team::query()->find(0))->not->toBeNull()
->and(PrivateKey::query()->find(1))->not->toBeNull()
->and(Server::query()->count())->toBe(1)
->and(Server::query()->find(0)?->uuid)->toBe('localhost')
->and(StandaloneDocker::query()->find(0))->not->toBeNull()
->and(GithubApp::query()->find(0))->not->toBeNull()
->and(GitlabApp::query()->find(1))->not->toBeNull()
->and(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
$project = Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->firstOrFail();
expect($project)
->not->toBeNull()
->and($project->environments)->toHaveCount(limaServers()->count());
foreach (limaServers() as $limaServer) {
$server = Server::query()->where('uuid', $limaServer['server_uuid'])->first();
expect($server)->not->toBeNull();
expect($server->settings->sentinel_custom_url)->toBe('http://host.lima.internal:8000');
expect(StandaloneDocker::query()->whereRelation('server', 'uuid', $limaServer['server_uuid'])->exists())->toBeTrue();
expect($project->environments()->where('uuid', $limaServer['environment_uuid'])->exists())->toBeTrue();
}
expect($project->environments)->toHaveCount(1)
->and($project->environments->first()->name)->toBe('production')
->and($project->applications()->whereRelation('destination.server', 'uuid', 'localhost')->count())
->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
it('seeds the railpack examples in development mode', function () {
it('seeds every railpack example in the production environment on testing-host', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$legacyProject = Project::query()->create([
'uuid' => 'railpack-examples-lima-ubuntu-2404',
'name' => 'Railpack Examples - lima-ubuntu-2404',
'description' => 'Legacy generated Railpack examples project',
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->firstOrFail();
$environment = $project->environments()->sole();
$applications = $environment->applications()->with('settings', 'destination.server')->orderBy('uuid')->get();
expect($environment->name)->toBe('production')
->and($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()))
->and($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue()
->and($applications->every(fn (Application $application) => $application->destination->server->uuid === 'localhost'))->toBeTrue()
->and($applications->pluck('uuid')->sort()->values()->all())
->toBe(collect(DevelopmentRailpackExamplesSeeder::examples())->pluck('uuid')->sort()->values()->all());
$nestjs = $applications->firstWhere('uuid', 'railpack-nestjs');
$angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static');
$githubDeployKey = $applications->firstWhere('uuid', 'railpack-github-deploy-key');
$gitlabDeployKey = $applications->firstWhere('uuid', 'railpack-gitlab-deploy-key');
expect($nestjs->base_directory)->toBe('/node/nestjs')
->and($nestjs->build_command)->toBe('npm run build')
->and($nestjs->start_command)->toBe('npm run start:prod')
->and($angularStatic->publish_directory)->toBe('/dist/static/browser')
->and($angularStatic->settings->is_static)->toBeTrue()
->and($githubDeployKey->private_key_id)->toBe(1)
->and($githubDeployKey->source_type)->toBe(GithubApp::class)
->and($gitlabDeployKey->source_type)->toBe(GitlabApp::class);
});
it('consolidates legacy railpack environments into production', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$project = Project::query()->create([
'uuid' => DevelopmentRailpackExamplesSeeder::PROJECT_UUID,
'name' => 'Railpack Examples',
'team_id' => 0,
]);
Application::query()->create([
'uuid' => 'lima-ubuntu-2404-railpack-nextjs-ssr',
'name' => 'Legacy Railpack Next.js SSR Example',
'repository_project_id' => DevelopmentRailpackExamplesSeeder::REPOSITORY_PROJECT_ID,
'git_repository' => DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY,
'git_branch' => DevelopmentRailpackExamplesSeeder::GIT_BRANCH,
'build_pack' => 'railpack',
'ports_exposes' => '3000',
'environment_id' => $legacyProject->environments()->first()->id,
'destination_id' => 0,
'destination_type' => StandaloneDocker::class,
'source_id' => 0,
'source_type' => GithubApp::class,
]);
$project->environments()->first()->update(['name' => 'ubuntu24', 'uuid' => 'railpack-examples-ubuntu24']);
$project->environments()->create(['name' => 'ubuntu26', 'uuid' => 'railpack-examples-ubuntu26']);
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
$project->refresh();
expect($project)
->not->toBeNull()
->and($project->name)->toBe('Railpack Examples')
->and($project->environments)->toHaveCount(limaServers()->count());
expect(Project::query()->pluck('uuid')->sort()->values()->all())->toBe([
'project',
DevelopmentRailpackExamplesSeeder::PROJECT_UUID,
]);
expect(Application::query()->where('uuid', 'lima-ubuntu-2404-railpack-nextjs-ssr')->exists())->toBeFalse();
$applications = $project->applications()->with('settings')->orderBy('uuid')->get();
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
expect($applications->every(
fn (Application $application) => $application->git_repository === ($examples->get(str($application->uuid)->after('-')->value())['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
))->toBeTrue();
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get(str($application->uuid)->after('-')->value())['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
foreach (limaServers() as $limaServer) {
$limaEnvironment = $project->environments()
->where('uuid', $limaServer['environment_uuid'])
->first();
expect($limaEnvironment)
->not->toBeNull()
->and($limaEnvironment->name)->toBe($limaServer['environment_name']);
$limaApplications = $limaEnvironment->applications()->with('settings', 'destination.server')->orderBy('uuid')->get();
expect($limaApplications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($limaApplications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
expect($limaApplications->every(fn (Application $application) => str($application->uuid)->startsWith($limaServer['uuid_prefix'])))->toBeTrue();
expect($limaApplications->every(fn (Application $application) => $application->destination->server->uuid === $limaServer['server_uuid']))->toBeTrue();
expect($limaApplications->every(
fn (Application $application) => $application->git_repository === ($examples->get(str($application->uuid)->after($limaServer['uuid_prefix'])->value())['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
))->toBeTrue();
expect($limaApplications->every(
fn (Application $application) => $application->git_branch === ($examples->get(str($application->uuid)->after($limaServer['uuid_prefix'])->value())['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
}
$nestjs = $applications->firstWhere('uuid', 'ubuntu24-railpack-nestjs');
$angularStatic = $applications->firstWhere('uuid', 'ubuntu24-railpack-angular-static');
$eleventyStatic = $applications->firstWhere('uuid', 'ubuntu24-railpack-eleventy-static');
$pythonFlask = $applications->firstWhere('uuid', 'ubuntu24-railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'ubuntu24-railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'ubuntu24-railpack-rust');
$githubDeployKey = $applications->firstWhere('uuid', 'ubuntu24-railpack-github-deploy-key');
$gitlabDeployKey = $applications->firstWhere('uuid', 'ubuntu24-railpack-gitlab-deploy-key');
$gitlabPublic = $applications->firstWhere('uuid', 'ubuntu24-railpack-gitlab-public-example');
expect($nestjs)
->not->toBeNull()
->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');
expect($githubDeployKey)
->not->toBeNull()
->and($githubDeployKey->git_repository)->toBe('git@github.com:coollabsio/coolify-examples-deploy-key.git')
->and($githubDeployKey->git_branch)->toBe('main')
->and($githubDeployKey->build_pack)->toBe('railpack')
->and($githubDeployKey->private_key_id)->toBe(1)
->and($githubDeployKey->source_type)->toBe(GithubApp::class)
->and($githubDeployKey->source_id)->toBe(0);
expect($gitlabDeployKey)
->not->toBeNull()
->and($gitlabDeployKey->git_repository)->toBe('git@gitlab.com:coollabsio/php-example.git')
->and($gitlabDeployKey->git_branch)->toBe('main')
->and($gitlabDeployKey->build_pack)->toBe('railpack')
->and($gitlabDeployKey->private_key_id)->toBe(1)
->and($gitlabDeployKey->source_type)->toBe(GitlabApp::class)
->and($gitlabDeployKey->source_id)->toBe(1);
expect($gitlabPublic)
->not->toBeNull()
->and($gitlabPublic->git_repository)->toBe('https://gitlab.com/andrasbacsai/coolify-examples.git')
->and($gitlabPublic->base_directory)->toBe('/astro/static')
->and($gitlabPublic->publish_directory)->toBe('/dist')
->and($gitlabPublic->build_pack)->toBe('railpack')
->and($gitlabPublic->source_type)->toBe(GitlabApp::class)
->and($gitlabPublic->settings->is_static)->toBeTrue();
expect($project->environments)->toHaveCount(1)
->and($project->environments->first()->name)->toBe('production')
->and($project->applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
});
it('skips the railpack examples outside development mode', function () {
@@ -226,13 +115,6 @@ it('skips the railpack examples outside development mode', function () {
$this->seed(DevelopmentRailpackExamplesSeeder::class);
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse();
expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse();
foreach (limaServers() as $limaServer) {
expect(Project::query()->where('uuid', 'railpack-examples-lima-ubuntu-2404')->exists())->toBeFalse();
expect(Project::query()->where('uuid', 'railpack-examples-lima-ubuntu-2604')->exists())->toBeFalse();
expect(Application::query()->where('uuid', $limaServer['uuid_prefix'].'railpack-nextjs-ssr')->exists())->toBeFalse();
}
});
it('is idempotent when run multiple times', function () {
@@ -242,19 +124,8 @@ it('is idempotent when run multiple times', function () {
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
$project = Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->firstOrFail();
expect($project)->not->toBeNull();
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
foreach (limaServers() as $limaServer) {
$limaEnvironment = $project->environments()
->where('uuid', $limaServer['environment_uuid'])
->first();
expect($limaEnvironment)->not->toBeNull();
expect($limaEnvironment->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
}
expect($project->environments)->toHaveCount(1)
->and($project->applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
});
+50
View File
@@ -0,0 +1,50 @@
<?php
use App\Support\DnsRecordHints;
it('builds a and aaaa records for a single hostname', function () {
$records = DnsRecordHints::forTarget('app.example.com', '203.0.113.10', '2001:db8::1');
expect($records)->toHaveCount(2)
->and($records[0])->toMatchArray([
'type' => 'A',
'name' => 'app.example.com',
'value' => '203.0.113.10',
])
->and($records[1])->toMatchArray([
'type' => 'AAAA',
'name' => 'app.example.com',
'value' => '2001:db8::1',
]);
});
it('builds entries for every hostname without duplicates', function () {
$records = DnsRecordHints::forHostnames([
'app.example.com',
'www.example.com',
'app.example.com',
'https://api.example.com/path',
], '203.0.113.10');
expect($records)->toHaveCount(3)
->and(collect($records)->pluck('name')->all())->toBe([
'api.example.com',
'app.example.com',
'www.example.com',
])
->and(collect($records)->pluck('type')->unique()->all())->toBe(['A']);
});
it('formats a BIND-compatible zone snippet for copy all', function () {
$text = DnsRecordHints::toCopyText([
['type' => 'A', 'name' => 'app.example.com', 'value' => '203.0.113.10'],
['type' => 'A', 'name' => 'www.example.com', 'value' => '203.0.113.10'],
['type' => 'AAAA', 'name' => 'app.example.com', 'value' => '2001:db8::1'],
]);
expect($text)->toBe(
"app.example.com. IN A 203.0.113.10\n".
"www.example.com. IN A 203.0.113.10\n".
"app.example.com. IN AAAA 2001:db8::1\n"
);
});
@@ -0,0 +1,19 @@
<?php
test('docker cleanup execution output uses light and dark theme surfaces', function () {
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
expect($view)
->toContain('bg-white text-neutral-700')
->toContain('dark:bg-neutral-950 dark:text-neutral-300');
});
test('docker cleanup executions show a loading state while opening an execution', function () {
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
expect($view)
->toContain('wire:loading.attr="disabled"')
->toContain('wire:target="selectExecution({{ data_get($execution, \'id\') }})"')
->toContain('wire:loading.remove')
->toContain('<x-loading wire:loading');
});
@@ -0,0 +1,23 @@
<?php
it('keeps spacing around the Docker Compose editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
expect($view)
->toContain('class="application-settings-section-body"')
->not->toContain('class="application-settings-section-body p-0!"');
});
it('lets the Docker Compose editor use the available width', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
expect($view)
->toContain('class="mt-8 w-full lg:mt-3"')
->not->toContain('max-w-[1180px]');
});
it('shows a loading indicator while creating the service', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
expect($view)->toContain('<x-forms.button type="submit" wire:target="submit" isHighlighted>');
});
@@ -0,0 +1,9 @@
<?php
it('shows that image tag and digest are mutually exclusive', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/docker-image.blade.php'));
expect($view)
->toContain('aria-label="Tag and SHA256 digest are mutually exclusive"')
->toContain('>OR</span>');
});
@@ -0,0 +1,80 @@
<?php
test('edit domain dialogs close after saving and omit redundant cancel actions', function () {
foreach ([
resource_path('views/livewire/project/application/domains.blade.php'),
resource_path('views/livewire/project/service/domains.blade.php'),
] as $viewPath) {
$view = file_get_contents($viewPath);
expect($view)
->toContain('@edit-domain-saved.window="closeEditDomain()"')
->toContain('<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>')
->not->toContain("@click=\"closeEditDomain()\">\n Cancel");
}
});
it('provides a reusable domain chips form component', function () {
$component = file_get_contents(resource_path('views/components/forms/domain-chips.blade.php'));
expect($component)
->toContain('chip-input')
->toContain('@entangle($model)')
->toContain('class="chip"')
->toContain('chip-remove');
});
it('uses domain chips for application public access and compose services', function () {
$blade = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
expect($blade)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->toContain(':model="$composeDomainKey"')
->not->toContain('id="parsedServiceDomains.');
});
it('uses domain chips for service application domains and edit domain modal', function () {
$serviceIndex = file_get_contents(resource_path('views/livewire/project/service/index.blade.php'));
$editDomain = file_get_contents(resource_path('views/livewire/project/service/edit-domain.blade.php'));
expect($serviceIndex)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->toContain('Make publicly available')
->toContain('enablePublicAccess')
->toContain('disablePublicAccess')
->toContain("x-bind:disabled=\"!String(port ?? '').trim()\"")
->toContain('sm:flex-row sm:items-end')
->not->toContain('label="Make it publicly available"')
// Nested @if inside <x-forms.button> breaks Blade component compilation (ParseError endif).
->not->toContain('@if ($canTogglePublicAccess)');
expect($editDomain)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->not->toContain('id="fqdn"');
});
it('styles icon buttons with a visible hover state', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($utilities)
->toContain('@utility icon-button')
->toContain('hover:bg-neutral-100')
->toContain('dark:hover:bg-white/[0.07]');
expect($resourceCard)
->toContain('class="icon-button"')
->toContain('title="Manage domains"');
});
it('exposes enable and disable public access methods on service index', function () {
$source = file_get_contents(app_path('Livewire/Project/Service/Index.php'));
expect($source)
->toContain('public function enablePublicAccess(): void')
->toContain('public function disablePublicAccess(): void')
->toContain('$this->instantSave()');
});
@@ -0,0 +1,21 @@
<?php
use Illuminate\Support\Facades\Blade;
it('uses the Coolify base background for all empty states', function () {
$html = Blade::render('<x-empty title="Nothing found" />');
$css = file_get_contents(resource_path('css/app.css'));
expect($html)
->toContain('empty-state')
->not->toContain('bg-neutral-50')
->not->toContain('dark:bg-white/[0.02]')
->and($css)
->toContain('.application-settings-section-body:has(> .empty-state:only-child)')
->toContain('.application-settings-section-body.is-flush:has(> .empty-state:only-child)')
->toContain('padding: 1rem;')
->toContain('.empty-state')
->toContain('background: var(--coollabs-base);')
->toContain('.loading-state-card')
->toContain('border: 1px dashed var(--coollabs-line);');
});
+97
View File
@@ -0,0 +1,97 @@
<?php
it('renders the dashed empty-state card with title description and icon', function () {
$html = $this->blade(
'<x-empty title="No tags yet" description="Add a tag to group deployments." icon-name="tags" />'
);
$html->assertSee('No tags yet')
->assertSee('Add a tag to group deployments.')
->assertSee('empty-state', false)
->assertSee('border-dashed', false)
->assertSee('min-h-80', false);
});
it('renders empty states flush with no outer inset inside settings sections', function () {
$html = $this->blade(
<<<'BLADE'
<x-application.settings-section title="Scheduled tasks" flush>
<x-empty title="No scheduled tasks"
description="Create a task to run maintenance commands."
icon-name="browser-terminal" />
</x-application.settings-section>
BLADE
);
$html->assertSee('is-flush', false)
->assertSee('empty-state', false)
->assertSee('No scheduled tasks');
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.application-settings-section-body.is-flush .empty-state')
->toContain('.application-settings-section-body:has(> .empty-state:only-child)')
->toContain('.application-settings-section > .empty-state')
->toMatch('/\.application-settings-section-body\.is-flush \.empty-state[\s\S]*?margin:\s*0/');
});
it('insets empty states nested inside a flush settings section wrapper', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)->toContain('.application-settings-section-body.is-flush > div:has(> .empty-state:only-child)');
});
it('insets the backup executions empty state from the table header', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($view)->toMatch('/@empty\s*<div class="p-4">\s*<x-empty size="sm" title="No backup executions"/');
});
it('keeps backup execution actions compact', function () {
$css = file_get_contents(resource_path('css/app.css'));
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($css)
->toContain('grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
->and($view)
->toContain('title="Download backup" aria-label="Download backup"')
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
->toContain('title="Delete backup" aria-label="Delete backup"')
->toContain('<x-reicon name="trash" class="size-3.5" />');
});
it('uses the compact resource table styling for backup executions', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('class="data-table deployment-table-scroll"')
->toContain('data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]')
->toContain('data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5')
->toContain('flex min-h-11 items-center justify-between border-t')
->and($css)
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*49rem;/');
});
it('renders compact size without the full-page min height class', function () {
$html = $this->blade(
'<x-empty title="No cloud tokens" description="Add a provider token." icon-name="keys" size="sm" />'
);
$html->assertSee('No cloud tokens')
->assertSee('min-h-44', false)
->assertDontSee('min-h-80', false);
});
it('renders action content from both contents and actions slots', function () {
$contents = $this->blade(
'<x-empty title="Empty" icon-name="keys"><x-slot:contents><button type="button">From contents</button></x-slot:contents></x-empty>'
);
$contents->assertSee('From contents');
$actions = $this->blade(
'<x-empty title="Empty" icon-name="keys"><x-slot:actions><button type="button">From actions</button></x-slot:actions></x-empty>'
);
$actions->assertSee('From actions');
});
@@ -0,0 +1,17 @@
<?php
it('uses highlighted styling for enable toggle actions', function () {
$views = [
resource_path('views/components/notification/channel-actions.blade.php'),
resource_path('views/livewire/settings-oauth.blade.php'),
resource_path('views/livewire/project/shared/scheduled-task/show.blade.php'),
];
foreach ($views as $view) {
expect(file_get_contents($view))->toContain('isHighlighted');
}
$terminalAccess = file_get_contents(resource_path('views/livewire/server/security/terminal-access.blade.php'));
expect($terminalAccess)->toContain(':isHighlightedButton="!$isTerminalEnabled"');
});
+13
View File
@@ -0,0 +1,13 @@
<?php
it('uses the current listbox design for environment variable suggestions', function () {
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
expect($view)
->toContain('class="listbox-panel')
->toContain('class="listbox-option justify-start! gap-2.5!"')
->toContain("'bg-neutral-100 dark:bg-white/[0.08]': index === selectedIndex")
->toContain('border-warning/25 bg-warning/10')
->toContain('border-emerald-500/25 bg-emerald-500/10')
->not->toContain('dark:bg-coolgray-100');
});
+23
View File
@@ -0,0 +1,23 @@
<?php
test('environment delete button label is simply Delete', function () {
$view = file_get_contents(resource_path('views/livewire/project/delete-environment.blade.php'));
expect($view)
->toContain('buttonTitle="Delete"')
->not->toContain('buttonTitle="Delete Environment"');
});
test('environment delete section stacks on small screens', function () {
$view = file_get_contents(resource_path('views/livewire/project/environment-edit.blade.php'));
expect($view)
->toContain('flex flex-col gap-4 px-5 py-4 sm:flex-row sm:items-start sm:justify-between')
->toContain('livewire:project.delete-environment');
});
test('app-tab utility includes icon text spacing', function () {
$css = file_get_contents(resource_path('css/utilities.css'));
expect($css)->toMatch('/@utility app-tab \{[^}]*gap-1/s');
});
@@ -183,7 +183,6 @@ test('convertContainerEnvsToArray', function () {
"FEATURE_FLAG_WITH_EQUALS=true=123",
"REGISTRY_URL=docker.io",
"SUBSCRIPTION_PROVIDER=stripe",
"TELESCOPE_ENABLED=false",
"POSTGRES_HOST_AUTH_METHOD=trust",
"DB_PASSWORD=password",
"SSH_MUX_ENABLED=true",
@@ -192,7 +191,6 @@ test('convertContainerEnvsToArray', function () {
"DB_HOST=host.docker.internal",
"POSTGRES_DB=coolify",
"APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB9mIxcl+k=",
"DEBUGBAR_ENABLED=false",
"APP_ID=development",
"DB_DATABASE=coolify",
"DUSK_DRIVER_URL=http://selenium:4444",
@@ -0,0 +1,123 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->actingAs($this->user);
});
it('does not load environment variables during mount', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application]);
expect($component->get('readyToLoad'))->toBeFalse()
->and($component->instance()->environmentVariables)->toHaveCount(0)
->and($component->get('variables'))->toBeNull();
});
it('loads environment variables when loadEnvironmentVariables is called', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->assertSee('Loading environment variables...')
->call('loadEnvironmentVariables')
->assertSet('readyToLoad', true)
->assertDontSee('Loading environment variables...')
->assertSee('API_KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toContain('API_KEY')
->toContain('DATABASE_URL');
});
it('builds developer view text only after switching to developer view', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables');
expect($component->get('variables'))->toBeNull();
$component->call('switch')->assertSet('view', 'dev');
expect($component->get('variables'))->toContain('API_KEY=secret');
});
it('is idempotent when loadEnvironmentVariables is called twice', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->call('loadEnvironmentVariables')
->assertSet('readyToLoad', true);
expect($component->instance()->environmentVariables->pluck('key')->all())
->toContain('API_KEY');
});
@@ -7,7 +7,7 @@ it('uses Alpine entangle to switch add value field immediately when multiline is
->toContain('x-data="{ isMultiline: $wire.entangle(\'is_multiline\') }"')
->toContain('<template x-if="isMultiline">')
->toContain('<template x-if="!isMultiline">')
->toContain('x-model="isMultiline"')
->toContain('id="is_multiline"')
->toContain('<x-forms.textarea id="value" label="Value" required class="font-sans" spellcheck />')
->toContain('wire:key="env-value-textarea"')
->toContain('wire:key="env-value-input"');
@@ -21,20 +21,22 @@ it('uses distinct keyed branches for the edit value field modes', function () {
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
});
it('lazy-loads decrypted values only when opening the edit modal', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('wire:click="loadValues"')
->toContain('wireOpen="editorOpen"')
->toContain('$valuesLoaded')
->toContain('Loading value...');
});
it('keeps the environment variable delete button compact', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)->not->toContain('buttonFullWidth="true"');
});
it('aligns environment variable settings and actions in a responsive row', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('class="flex w-full flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"')
->toContain('class="flex w-full justify-end gap-2 lg:w-auto lg:shrink-0"');
});
it('uses sans font for the developer bulk environment variable editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
@@ -44,27 +46,27 @@ it('uses sans font for the developer bulk environment variable editor', function
->not->toContain('wire:model="variablesPreview" monospace');
});
it('renders the environment variable search field above the production title', function () {
it('loads environment variables asynchronously after first paint', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect(strpos($view, 'aria-label="Search environment variables"'))
->toBeLessThan(strpos($view, '<h3>Production Environment Variables</h3>'));
expect($view)
->toContain('wire:init="loadEnvironmentVariables"')
->toContain('Loading environment variables...');
});
it('renders a single no results message for empty environment variable searches', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->isSearchActive && ! $this->hasEnvironmentVariables)')
->toContain('<div>No environment variables found.</div>')
->toContain('@else');
->toContain('@if ($this->isSearchActive && $totalRows === 0)')
->toContain('No environment variables found');
});
it('only renders the production section when production variables are visible', function () {
it('paginates a unified environment variable table instead of loading every row', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())')
->not->toContain('@forelse ($this->environmentVariables as $env)')
->not->toContain('@empty');
->toContain('$this->environmentVariablePageRows')
->toContain('nextEnvironmentVariablePage')
->not->toContain('@forelse ($this->environmentVariables as $env)');
});
@@ -0,0 +1,255 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->actingAs($this->user);
});
it('hides preview scope for non-git applications', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerimage',
'git_repository' => null,
]);
Livewire::test(All::class, ['resource' => $application])
->assertSet('showPreview', false);
});
it('paginates managed environment variables without loading every row into the page collection', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
// Remove factory defaults so counts stay predictable.
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->delete();
for ($i = 1; $i <= 25; $i++) {
EnvironmentVariable::create([
'key' => sprintf('VAR_%02d', $i),
'value' => "value-{$i}",
'order' => $i,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
// Creating production vars auto-mirrors preview rows; keep this test production-only.
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->where('is_preview', true)
->delete();
$component = Livewire::test(All::class, ['resource' => $application->fresh()])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariableRowCount)->toBe(25)
->and($component->instance()->environmentVariableLastPage)->toBe(3)
->and($component->instance()->environmentVariablePageRows)->toHaveCount(10)
->and($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe([
'VAR_01', 'VAR_02', 'VAR_03', 'VAR_04', 'VAR_05',
'VAR_06', 'VAR_07', 'VAR_08', 'VAR_09', 'VAR_10',
]);
$component->call('nextEnvironmentVariablePage');
expect($component->get('page'))->toBe(2)
->and($component->instance()->environmentVariablePageRows)->toHaveCount(10)
->and($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe([
'VAR_11', 'VAR_12', 'VAR_13', 'VAR_14', 'VAR_15',
'VAR_16', 'VAR_17', 'VAR_18', 'VAR_19', 'VAR_20',
]);
$component->call('setEnvironmentVariablePage', 3);
expect($component->instance()->environmentVariablePageRows)->toHaveCount(5)
->and($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe(['VAR_21', 'VAR_22', 'VAR_23', 'VAR_24', 'VAR_25']);
});
it('paginates across production then preview segments', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->delete();
for ($i = 1; $i <= 8; $i++) {
EnvironmentVariable::create([
'key' => sprintf('PROD_%02d', $i),
'value' => "prod-{$i}",
'order' => $i,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
// Creating production vars also auto-creates preview mirrors; replace with controlled preview set.
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->where('is_preview', true)
->delete();
for ($i = 1; $i <= 8; $i++) {
EnvironmentVariable::create([
'key' => sprintf('PREV_%02d', $i),
'value' => "prev-{$i}",
'order' => $i,
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
$component = Livewire::test(All::class, ['resource' => $application->fresh()])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariableRowCount)->toBe(16)
->and($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe([
'PROD_01', 'PROD_02', 'PROD_03', 'PROD_04', 'PROD_05',
'PROD_06', 'PROD_07', 'PROD_08', 'PREV_01', 'PREV_02',
]);
$component->call('nextEnvironmentVariablePage');
expect($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe([
'PREV_03', 'PREV_04', 'PREV_05', 'PREV_06', 'PREV_07', 'PREV_08',
]);
});
it('counts with a lightweight query rather than hydrating every model for the page total', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->delete();
for ($i = 1; $i <= 15; $i++) {
EnvironmentVariable::create([
'key' => sprintf('COUNT_%02d', $i),
'value' => "value-{$i}",
'order' => $i,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
// Drop auto-created preview mirrors so only production rows exist.
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->where('is_preview', true)
->delete();
$component = Livewire::test(All::class, ['resource' => $application->fresh()])
->call('loadEnvironmentVariables');
DB::enableQueryLog();
$count = $component->instance()->environmentVariableRowCount;
$pageRows = $component->instance()->environmentVariablePageRows;
$queries = collect(DB::getQueryLog());
expect($count)->toBe(15)
->and($pageRows)->toHaveCount(10);
$selectQueries = $queries->filter(fn (array $query) => str_contains(strtolower($query['query']), 'select'));
// One COUNT for the production segment + one SELECT for the page slice.
expect($selectQueries->count())->toBeLessThanOrEqual(4);
});
it('does not decrypt environment variable values until edit is opened', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->delete();
$env = EnvironmentVariable::create([
'key' => 'SECRET_KEY',
'value' => 'super-secret-value',
'order' => 1,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->where('is_preview', true)
->delete();
$component = Livewire::test(All::class, ['resource' => $application->fresh()])
->call('loadEnvironmentVariables');
$pageEnv = $component->instance()->environmentVariablePageRows->first()['environmentVariable'];
expect(array_key_exists('value', $pageEnv->getAttributes()))->toBeFalse()
->and($pageEnv->getAppends())->toBe([]);
$show = Livewire::test(Show::class, [
'env' => $pageEnv,
'type' => 'application',
]);
expect($show->get('valuesLoaded'))->toBeFalse()
->and($show->get('value'))->toBeNull()
->and($show->get('editorOpen'))->toBeFalse();
$show->set('editorOpen', true)->call('loadValues');
expect($show->get('valuesLoaded'))->toBeTrue()
->and($show->get('value'))->toBe('super-secret-value')
->and($show->get('editorOpen'))->toBeTrue();
});
+132 -7
View File
@@ -50,12 +50,61 @@ it('filters production environment variables by key case-insensitively', functio
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
});
it('orders environment variables by creation order or alphabetically based on the setting', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'ZEBRA',
'value' => 'last-key',
'order' => 1,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'ALPHA',
'value' => 'first-key',
'order' => 2,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->assertSet('is_env_sorting_enabled', false);
$userManagedKeys = $component->instance()->environmentVariables
->pluck('key')
->filter(fn (string $key) => in_array($key, ['ZEBRA', 'ALPHA'], true))
->values()
->all();
expect($userManagedKeys)->toBe(['ZEBRA', 'ALPHA']);
$component
->set('is_env_sorting_enabled', true)
->call('instantSave');
$alphabeticallyOrderedKeys = $component->instance()->environmentVariables
->pluck('key')
->filter(fn (string $key) => in_array($key, ['ZEBRA', 'ALPHA'], true))
->values()
->all();
expect($alphabeticallyOrderedKeys)
->toBe(['ALPHA', 'ZEBRA'])
->and($application->settings->fresh()->is_env_sorting_enabled)->toBeTrue();
});
it('treats production environment variable search underscore wildcards literally', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
@@ -76,6 +125,7 @@ it('treats production environment variable search underscore wildcards literally
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api_key');
expect($component->instance()->environmentVariables->pluck('key')->all())
@@ -104,6 +154,7 @@ it('filters preview environment variables by key case-insensitively', function (
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'token');
expect($component->instance()->environmentVariablesPreview->pluck('key')->all())
@@ -124,12 +175,41 @@ YAML,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->set('search', 'api');
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
->toBe(['API_TOKEN']);
});
it('shows a Compose-defined value as read-only when a managed variable has the same key', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
- API_TOKEN=from-compose
YAML,
]);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'from-environment-tab',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariablePageRows)
->toHaveCount(1)
->and($component->instance()->environmentVariablePageRows->first()['kind'])->toBe('hardcoded')
->and($component->instance()->environmentVariablePageRows->first()['environmentVariable']['value'])->toBe('from-compose');
});
it('searches service environment variables without requiring preview variables', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
@@ -150,12 +230,55 @@ it('searches service environment variables without requiring preview variables',
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertDontSee('Preview Deployments Environment Variables');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
->toBe(['API_KEY'])
->and($component->instance()->showPreview)->toBeFalse();
});
it('pins required service environment variables only while their values are missing', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
HARDCODED_FIRST: configured
YAML,
]);
EnvironmentVariable::create([
'key' => 'OPTIONAL_FIRST',
'value' => 'configured',
'order' => 1,
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$required = EnvironmentVariable::create([
'key' => 'REQUIRED_SECOND',
'value' => '',
'order' => 2,
'is_required' => true,
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe(['REQUIRED_SECOND', 'OPTIONAL_FIRST', 'HARDCODED_FIRST']);
$required->update(['value' => 'configured']);
$component->call('$refresh');
expect($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe(['OPTIONAL_FIRST', 'REQUIRED_SECOND', 'HARDCODED_FIRST']);
});
it('does not show the empty production message when search only matches hardcoded variables', function () {
@@ -172,9 +295,9 @@ YAML,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertDontSee('No environment variables found.');
->assertDontSee('No environment variables found');
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
->toBe(['API_TOKEN']);
@@ -200,6 +323,7 @@ it('keeps developer view unfiltered after searching', function () {
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api')
->call('switch')
->assertSet('view', 'dev');
@@ -229,6 +353,7 @@ it('does not delete non-matching variables when saving developer view after sear
]);
Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api')
->call('switch')
->call('submit');
@@ -261,11 +386,11 @@ it('hides the preview section when search filters out all preview variables', fu
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertDontSee('Preview Deployments Environment Variables')
->assertDontSee('PREVIEW_TOKEN');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
->toBe(['API_KEY'])
->and($component->instance()->environmentVariablesPreview)->toHaveCount(0);
});
@@ -0,0 +1,142 @@
<?php
/**
* Resource environment variables table: full names, Managed as a column,
* Type owns Production/Preview (no desktop Production badge in the name cell).
*/
test('resource environment variables table has a Managed column and no name-cell Production badge on desktop', function () {
$all = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
$filter = file_get_contents(resource_path('views/components/table/filter.blade.php'));
$loading = file_get_contents(resource_path('views/components/table/loading.blade.php'));
// Header includes Managed between Name and Type.
expect($all)
->toContain('$showEnvironmentType = $showPreview')
->toContain("toggleVariableFilter('{{ \$value }}')")
->toContain('toggleServiceFilter(@js($serviceName))')
->toContain('$this->serviceFilterOptions')
->toContain('toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter')
->toContain('<x-table.loading target="clearFilters"')
->toContain('reset-action="clearFilters"')
->not->toContain("'all' => 'All variables'")
->toContain("setTableSort('{{ \$value }}')")
->toContain('Loading environment variables...')
->toContain('opacity-40 blur-[2px]')
->toContain('setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage')
->toContain("'buildtime' => 'Buildtime'")
->toContain("'runtime' => 'Runtime'")
->toContain("'multiline' => 'Multiline'")
->toContain("'literal' => 'Literal'")
->toContain('$activeFilterCount')
->toContain('$activeFilterText')
->toContain("'flex size-4 shrink-0 items-center justify-center rounded-[5px] border'")
->toContain('m2.25 6.15 2.35 2.3 5.15-5')
->toContain('>Name</span>')
->toContain('>Managed</span>')
->toContain('>Type</span>')
->and($filter)
->toContain('Reset filters')
->toContain('max-h-80 overflow-y-auto p-1')
->toContain('min-w-44! overflow-hidden! p-0!')
->and($loading)
->toContain('wire:loading.flex');
// Name cell does not repeat the environment type; Type owns Production/Preview.
expect($show)
->toContain('env-managed-desktop')
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile')
->toContain('env-managed-desktop data-table-cell-check')
->toContain('$isMagicVariable');
// Production/Managed desktop badges must not sit bare in the name cell without mobile class.
expect($show)->not->toMatch(
'/env-key-label[\s\S]{0,400}<span class="table-badge shrink-0">Managed<\/span>/'
);
expect($show)->not->toMatch(
'/env-key-label[\s\S]{0,500}<span class="table-badge shrink-0">\{\{\s*\$rowScopeLabel\s*\}\}<\/span>/'
);
expect($hardcoded)
->toContain('env-managed-desktop data-table-cell-check')
->toContain('title="Environment variable details"')
->toContain('<x-forms.input label="Value" :value="$value ?? \'\'" readonly />')
->not->toContain("{{ filled(\$value) ? \$value : '(empty)' }}")
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile');
// Mobile-only badge classes beat .table-badge display on desktop.
expect($css)
->toContain('.env-managed-desktop')
// Resource grid is 9 columns (includes Managed).
->toContain('minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem');
expect($all)->not->toContain('<span>Comment</span>');
expect($show)->toContain('<x-helper :helper="e($comment)" />');
});
test('resource environment variables table remains horizontally scrollable on mobile', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('environment-table-scroll')
->and($css)
->toContain(".environment-table-scroll {\n overflow-x: auto;")
->toContain(".environment-table-scroll .env-table-grid {\n min-width: 53rem;")
->not->toContain('.data-table-row.env-table-grid > :nth-child')
->not->toContain(".env-type-desktop {\n display: none");
});
test('shared environment variables table still omits Managed column', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($editor)
->toContain('env-table-grid-shared')
->not->toContain('>Managed</span>');
// Shared rows skip the Managed column cell.
expect($show)->toContain('! $isSharedVariable');
});
test('shared environment variables empty state has spacing around the card', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
expect($editor)
->toMatch('/<div class="p-3">\s*<x-empty title="No shared variables"/');
});
test('managed environment variables are ordered first', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/EnvironmentVariable/All.php'));
expect($component)
->toContain("CASE WHEN key LIKE 'SERVICE_FQDN%'")
->toMatch("/'kind' => 'managed',[\\s\\S]+?'kind' => 'hardcoded'/");
});
test('missing required environment variables are ordered before generated service variables', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/EnvironmentVariable/All.php'));
$requiredOrder = strpos($component, '$this->missingRequiredEnvironmentVariableIds($isPreview)', strpos($component, 'private function managedEnvironmentVariablesQuery'));
$generatedOrder = strpos($component, "CASE WHEN key LIKE 'SERVICE_FQDN%'", strpos($component, 'private function managedEnvironmentVariablesQuery'));
expect($requiredOrder)
->not->toBeFalse()
->toBeLessThan($generatedOrder)
->and($component)
->toContain('->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required)');
});
test('environment variable toolbar does not use blade directives inside component attributes', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->not->toMatch('/<x-table\.toolbar[^>]*@if/')
->toContain('aria-busy="{{ ! $readyToLoad ? \'true\' : \'false\' }}"');
});
+85
View File
@@ -0,0 +1,85 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
});
it('renders redesigned error pages with shared shell and actions', function (string $view, string $code, string $title) {
$exception = new HttpException((int) $code, 'Test error message');
$html = view($view, ['exception' => $exception])->render();
expect($html)
->toContain('error-page-body')
->toContain('error-shell')
->toContain('error-code')
->toContain('error-title')
->toContain('error-actions')
->toContain($code)
->toContain($title)
->toContain('Contact support')
->toContain(config('constants.urls.contact'));
})->with([
'400' => ['errors.400', '400', 'Bad request'],
'401' => ['errors.401', '401', 'You shall not pass!'],
'402' => ['errors.402', '402', 'Payment required'],
'403' => ['errors.403', '403', 'You shall not pass!'],
'404' => ['errors.404', '404', 'How did you get here?'],
'419' => ['errors.419', '419', 'This page is definitely old, not like you!'],
'429' => ['errors.429', '429', 'Woah, slow down there!'],
'500' => ['errors.500', '500', 'Wait, this is not cool...'],
'503' => ['errors.503', '503', 'We are working on serious things.'],
]);
it('includes go back and dashboard actions on standard error pages', function () {
$exception = new HttpException(404, 'Not found');
$html = view('errors.404', ['exception' => $exception])->render();
expect($html)
->toContain('Go back')
->toContain('Dashboard')
->toContain(route('dashboard'));
});
it('uses login as primary action on session expired page', function () {
$exception = new HttpException(419, 'Page expired');
$html = view('errors.419', ['exception' => $exception])->render();
expect($html)
->toContain('/login')
->toContain('Back to login')
->toContain('Using a reverse proxy or Cloudflare Tunnel?')
->toContain('x-data="{ open: false }"')
->toContain('x-on:click="open = !open"')
->toContain('livewire.js')
->not->toContain('>Dashboard</');
});
it('renders contact support as a button', function () {
$exception = new HttpException(404, 'Not found');
$html = view('errors.404', ['exception' => $exception])->render();
expect($html)
->toContain('href="'.config('constants.urls.contact').'"')
->toMatch('/<button[^>]*>\s*Contact support/s');
});
it('shows purified exception message on 500 page', function () {
$exception = new RuntimeException('Database connection failed');
$html = view('errors.500', ['exception' => $exception])->render();
expect($html)
->toContain('error-code-danger')
->toContain('error-message')
->toContain('Database connection failed');
});
@@ -0,0 +1,26 @@
<?php
it('uses the simplified database backup execution heading and spacing', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($view)
->toContain('<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">')
->toContain('<h2 class="py-0">Executions</h2>')
->not->toContain('Executions <span')
->toContain('class="flex flex-col gap-4 pt-2"');
});
it('renders scheduled task executions as selectable status cards with expandable logs', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/scheduled-task/executions.blade.php'));
expect($view)
->toContain('<div class="flex flex-col gap-2" wire:poll.5000ms="refreshExecutions"')
->toContain('<a wire:click="selectTask({{ data_get($execution, \'id\') }})"')
->toContain('border-l-2 transition-colors p-4 cursor-pointer')
->toContain("'success' => 'Success'")
->toContain("'running' => 'In Progress'")
->toContain("'failed' => 'Failed'")
->toContain("data_get(\$execution, 'id') == \$selectedKey")
->toContain('max-h-[600px] overflow-y-auto')
->toContain('No executions found.');
});
+102 -3
View File
@@ -1,14 +1,19 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\FileStorage;
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\Storages\All;
use App\Livewire\Project\Shared\Storages\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
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;
@@ -82,7 +87,7 @@ test('livewire file storage rejects parent segments and does not create a local
test('file mount modal shows the calculated host file path above the destination input', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSeeText('This file will be created on the host, then mounted into the container.')
->assertSeeText('Create a managed file on the host and mount it inside the container.')
->assertSeeText('Host file path')
->assertSeeText($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf')
@@ -95,7 +100,8 @@ test('livewire file storage stores safe file mounts under the application config
->set('file_storage_path', '/etc/nginx/nginx.conf')
->set('file_storage_content', 'server {}')
->call('submitFileStorage')
->assertDispatched('success');
->assertDispatched('success')
->assertDispatched('configurationChanged');
$volume = LocalFileVolume::query()->sole();
@@ -109,7 +115,8 @@ test('livewire host file storage stores an existing host file path without manag
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
->call('submitHostFileStorage')
->assertDispatched('success');
->assertDispatched('success')
->assertDispatched('configurationChanged');
$volume = LocalFileVolume::query()->sole();
@@ -121,3 +128,95 @@ test('livewire host file storage stores an existing host file path without manag
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
test('livewire volume storage refreshes the storage list and configuration warning', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('name', 'data')
->set('mount_path', '/app/data')
->call('submitPersistentVolume')
->assertDispatched('success')
->assertDispatched('refreshStorages')
->assertDispatched('configurationChanged')
->assertSet('activeTab', 'volumes')
->assertSet('volumeCount', 1)
->assertSee($this->application->uuid.'-data');
});
test('volume storage list shows volumes added after it was mounted', function () {
$firstVolume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-first',
'mount_path' => '/app/first',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$storageList = Livewire::test(All::class, ['resource' => $this->application])
->assertSee($firstVolume->name);
$secondVolume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-second',
'mount_path' => '/app/second',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$storageList
->assertDontSee($secondVolume->name)
->call('refreshList')
->assertSee($secondVolume->name);
});
test('adding a volume switches to the volumes tab immediately', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSet('activeTab', 'volumes')
->set('activeTab', 'directories')
->set('name', 'cache')
->set('mount_path', '/cache')
->call('submitPersistentVolume')
->assertSet('activeTab', 'volumes')
->assertSee($this->application->uuid.'-cache')
->assertDontSee('No directory mounts configured');
});
test('deleting a file mount refreshes the configuration warning', function () {
$file = LocalFileVolume::create([
'fs_path' => '/etc/nginx/nginx.conf',
'mount_path' => '/etc/nginx/nginx.conf',
'is_host_file' => true,
'is_based_on_git' => false,
'is_preview_suffix_enabled' => true,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
Livewire::test(FileStorage::class, ['fileStorage' => $file])
->call('delete', 'password')
->assertDispatched('configurationChanged');
expect($file->fresh())->toBeNull();
});
test('deleting a volume mount refreshes the configuration warning', function () {
$database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$volume = LocalPersistentVolume::create([
'name' => $database->uuid.'-data',
'mount_path' => '/var/lib/postgresql/data',
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
]);
Livewire::test(Show::class, ['storage' => $volume, 'resource' => $database])
->call('delete', 'password')
->assertDispatched('configurationChanged');
expect($volume->fresh())->toBeNull();
});
+126
View File
@@ -0,0 +1,126 @@
<?php
use App\Models\V5\Server as V5Server;
use App\Services\Flux\AgentTokenIssuer;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
it('issues production host tokens with the default capability profile', function () {
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
Config::set('flux.jwt_private_key_path', $privateKeyPath);
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
expect($claims->sub)->toBe('100.64.0.10')
->and($claims->aud)->toBe('coold')
->and((array) $claims->caps)->toBe(config('flux.host_capabilities'))
->and((array) $claims->caps)->not->toContain('host-agent:default')
->and($claims->exp)->toBeGreaterThan(time());
});
it('issues production server tokens with server identity claims', function () {
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
Config::set('flux.jwt_private_key_path', $privateKeyPath);
$server = new V5Server;
$server->forceFill([
'uuid' => 'server_prod_123',
'id' => 123,
'team_id' => 7,
'cluster_id' => 'cluster-456',
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '203.0.113.10',
]);
$token = app(AgentTokenIssuer::class)->issueForServer($server);
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
expect($claims->sub)->toBe('server_prod_123')
->and((array) $claims->caps)->toBe(config('flux.host_capabilities'))
->and($claims->team_id)->toBe('7')
->and($claims->cluster_id)->toBe('cluster-456')
->and($claims->server_id)->toBe('server_prod_123')
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
});
it('mints a host jwt signed by the configured flux private key', function () {
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
Config::set('flux.jwt_private_key_path', $privateKeyPath);
$exitCode = Artisan::call('flux:dev', [
'host_id' => 'coold-dev',
'--caps' => 'containers.list,ingress.apply',
'--ttl' => '600',
]);
expect($exitCode)->toBe(0);
$token = trim(Artisan::output());
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
expect($claims->sub)->toBe('coold-dev')
->and($claims->aud)->toBe('coold')
->and($claims->caps)->toBe(['containers.list', 'ingress.apply'])
->and($claims->exp)->toBeGreaterThan(time());
});
it('mints a host jwt with the dev capability profile by default', function () {
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
Config::set('flux.jwt_private_key_path', $privateKeyPath);
$exitCode = Artisan::call('flux:dev', [
'host_id' => 'coold-dev',
'--ttl' => '600',
]);
expect($exitCode)->toBe(0);
$token = trim(Artisan::output());
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
expect($claims->caps)->toBe(['host-agent:dev']);
});
it('writes the host jwt to an output path with owner-only permissions', function () {
[$privateKeyPath] = createFluxJwtKeypair();
$outputPath = storage_path('framework/testing/host-jwt');
Config::set('flux.jwt_private_key_path', $privateKeyPath);
$exitCode = Artisan::call('flux:dev', [
'host_id' => 'coold-dev',
'--output' => $outputPath,
]);
expect($exitCode)->toBe(0);
expect($outputPath)->toBeFile()
->and(substr(sprintf('%o', fileperms($outputPath)), -4))->toBe('0600');
});
/**
* @return array{0: string, 1: string}
*/
function createFluxJwtKeypair(): array
{
$directory = storage_path('framework/testing/flux-keys-'.bin2hex(random_bytes(4)));
mkdir($directory, 0777, true);
$privateKeyPath = $directory.'/jwt.priv';
$publicKeyPath = $directory.'/jwt.pub';
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $output, $exitCode);
expect($exitCode)->toBe(0);
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $output, $exitCode);
expect($exitCode)->toBe(0);
return [$privateKeyPath, $publicKeyPath];
}
@@ -0,0 +1,72 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
Once::flush();
});
function ensureLoginUserExists(): void
{
if (User::count() === 0) {
User::factory()->create();
}
}
test('login shows active forgot password link when transactional email is enabled', function () {
InstanceSettings::query()->forceCreate([
'id' => 0,
'smtp_enabled' => true,
'smtp_from_address' => 'hi@localhost.com',
'smtp_from_name' => 'Coolify',
'smtp_host' => 'coolify-mail',
'smtp_port' => 1025,
]);
Once::flush();
ensureLoginUserExists();
$this->get('/login')
->assertSuccessful()
->assertSee(__('auth.forgot_password_link'), false)
->assertSee('href="/forgot-password"', false)
->assertDontSee(__('auth.forgot_password_disabled_tooltip'), false)
->assertDontSee('aria-disabled="true"', false);
});
test('login disables forgot password with tooltip when transactional email is not configured', function () {
InstanceSettings::query()->forceCreate([
'id' => 0,
'smtp_enabled' => false,
'resend_enabled' => false,
]);
Once::flush();
ensureLoginUserExists();
$this->get('/login')
->assertSuccessful()
->assertSee(__('auth.forgot_password_link'), false)
->assertSee(__('auth.forgot_password_disabled_tooltip'), false)
->assertSee('aria-disabled="true"', false)
->assertDontSee('href="/forgot-password"', false);
});
test('login enables forgot password when only resend is configured', function () {
InstanceSettings::query()->forceCreate([
'id' => 0,
'smtp_enabled' => false,
'resend_enabled' => true,
'resend_api_key' => 're_test_key',
]);
Once::flush();
ensureLoginUserExists();
$this->get('/login')
->assertSuccessful()
->assertSee('href="/forgot-password"', false)
->assertDontSee('aria-disabled="true"', false);
});
+21 -4
View File
@@ -27,9 +27,9 @@ it('rate limits repeated forgot password attempts from the same ip', function ()
it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () {
$emails = [
'ke.vinmcfadden+one@btinternet.com',
'kevin.mcfadden+two@btinternet.com',
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
'ke.vinmcfadden+one@gmail.com',
'kevin.mcfadden+two@gmail.com',
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com',
];
foreach ($emails as $index => $email) {
@@ -42,7 +42,24 @@ it('rate limits dotted plus-address forgot password variants of the same email i
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
->post('/forgot-password', [
'email' => 'k.evin.mcfadden+four@btinternet.com',
'email' => 'k.evin.mcfadden+four@gmail.com',
])
->assertTooManyRequests();
});
it('keeps distinct dotted and plus-addressed mailboxes in separate forgot password buckets on ordinary domains', function () {
$emails = [
'john.smith@example.com',
'johnsmith@example.com',
'johnsmith+one@example.com',
'johnsmith+two@example.com',
];
foreach ($emails as $index => $email) {
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 120)])
->post('/forgot-password', [
'email' => $email,
])
->assertSessionHasNoErrors();
}
});
Loaded 100 of 1274 files, more files were not shown because too many files have changed in this diff. Show more