Merge remote-tracking branch 'origin/next' into feat/railpack

This commit is contained in:
Andras Bacsai committed 2026-03-23 21:58:33 +01:00
commit 2ed360f0e0
44 files changed
+4009 -366

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
<?php
use App\Jobs\DockerCleanupJob;
use App\Models\DockerCleanupExecution;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates a failed execution record when server is not functional', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
// Make server not functional by setting is_reachable to false
$server->settings->update(['is_reachable' => false]);
$job = new DockerCleanupJob($server);
$job->handle();
$execution = DockerCleanupExecution::where('server_id', $server->id)->first();
expect($execution)->not->toBeNull()
->and($execution->status)->toBe('failed')
->and($execution->message)->toContain('not functional')
->and($execution->finished_at)->not->toBeNull();
});
it('creates a failed execution record when server is force disabled', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
// Make server not functional by force disabling
$server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => true,
]);
$job = new DockerCleanupJob($server);
$job->handle();
$execution = DockerCleanupExecution::where('server_id', $server->id)->first();
expect($execution)->not->toBeNull()
->and($execution->status)->toBe('failed')
->and($execution->message)->toContain('not functional');
});
@@ -0,0 +1,22 @@
<?php
test('generate_application_name strips owner from git repository', function () {
$name = generate_application_name('coollabsio/coolify', 'main', 'test123');
expect($name)->toBe('coolify:main-test123');
expect($name)->not->toContain('coollabsio');
});
test('generate_application_name handles repository without owner', function () {
$name = generate_application_name('coolify', 'main', 'test123');
expect($name)->toBe('coolify:main-test123');
});
test('generate_application_name handles deeply nested repository path', function () {
$name = generate_application_name('org/sub/repo-name', 'develop', 'abc456');
expect($name)->toBe('repo-name:develop-abc456');
expect($name)->not->toContain('org');
expect($name)->not->toContain('sub');
});
+70
View File
@@ -0,0 +1,70 @@
<?php
describe('GitHub Manual Webhook', function () {
test('ping event returns pong', function () {
$response = $this->postJson('/webhooks/source/github/events/manual', [], [
'X-GitHub-Event' => 'ping',
]);
$response->assertOk();
$response->assertSee('pong');
});
test('unsupported event type returns graceful response instead of 500', function () {
$payload = [
'action' => 'published',
'registry_package' => [
'ecosystem' => 'CONTAINER',
'package_type' => 'CONTAINER',
'package_version' => [
'target_commitish' => 'main',
],
],
'repository' => [
'full_name' => 'test-org/test-repo',
'default_branch' => 'main',
],
];
$response = $this->postJson('/webhooks/source/github/events/manual', $payload, [
'X-GitHub-Event' => 'registry_package',
'X-Hub-Signature-256' => 'sha256=fake',
]);
$response->assertOk();
$response->assertSee('not supported');
});
test('unknown event type returns graceful response', function () {
$response = $this->postJson('/webhooks/source/github/events/manual', ['foo' => 'bar'], [
'X-GitHub-Event' => 'some_unknown_event',
'X-Hub-Signature-256' => 'sha256=fake',
]);
$response->assertOk();
$response->assertSee('not supported');
});
});
describe('GitHub Normal Webhook', function () {
test('unsupported event type returns graceful response instead of 500', function () {
$payload = [
'action' => 'published',
'registry_package' => [
'ecosystem' => 'CONTAINER',
],
'repository' => [
'full_name' => 'test-org/test-repo',
],
];
$response = $this->postJson('/webhooks/source/github/events', $payload, [
'X-GitHub-Event' => 'registry_package',
'X-GitHub-Hook-Installation-Target-Id' => '12345',
'X-Hub-Signature-256' => 'sha256=fake',
]);
// Should not be a 500 error - either 200 with "not supported" or "No GitHub App found"
$response->assertOk();
});
});
@@ -1,271 +1,168 @@
<?php
use App\Jobs\ScheduledJobManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
// Clear any dedup keys
Cache::flush();
});
it('dispatches backup when job runs on time at the cron minute', function () {
// Freeze time at exactly 02:00 — daily cron "0 2 * * *" is due
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
// Use reflection to test shouldRunNow
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
expect($result)->toBeTrue();
});
it('catches delayed job when cache has a baseline from previous run', function () {
// Simulate a previous dispatch yesterday at 02:00
Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
// Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
// lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
expect($result)->toBeTrue();
});
it('does not double-dispatch on subsequent runs within same cron window', function () {
// First run at 02:00 — dispatches and sets cache
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
$first = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
expect($first)->toBeTrue();
// Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
$second = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
expect($second)->toBeFalse();
});
it('fires every_minute cron correctly on consecutive minutes', function () {
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Minute 1
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
expect($result1)->toBeTrue();
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
// Minute 2
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
expect($result2)->toBeTrue();
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
// Minute 3
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
expect($result3)->toBeTrue();
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
});
it('does not fire non-due jobs on restart when cache is empty', function () {
// Time is 10:00, cron is daily at 02:00 — NOT due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4');
expect($result)->toBeFalse();
});
it('fires due jobs on restart when cache is empty', function () {
// Time is exactly 02:00, cron is daily at 02:00 — IS due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Cache is empty (fresh restart) — but cron IS due → should fire
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4b');
expect($result)->toBeTrue();
});
it('does not dispatch when cron is not due and was not recently due', function () {
// Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// previousDue = 02:00, but lastDispatched was set at 02:00 (simulate)
Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:5');
expect($result)->toBeFalse();
});
it('falls back to isDue when no dedup key is provided', function () {
// Time is exactly 02:00, cron is "0 2 * * *" — should be due
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeTrue();
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// No dedup key → simple isDue check
$result = $method->invoke($job, '0 2 * * *', 'UTC');
expect($result)->toBeTrue();
// At 02:01 without dedup key → isDue returns false
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result2 = $method->invoke($job, '0 2 * * *', 'UTC');
expect($result2)->toBeFalse();
expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeFalse();
});
it('catches delayed docker cleanup when job runs past the cron minute', function () {
// Simulate a previous dispatch at :10
Cache::put('docker-cleanup:42', Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')->toIso8601String(), 86400);
// Freeze time at :22 — job was delayed 2 minutes past the :20 cron window
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 22, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// isDue() would return false at :22, but getPreviousRunDate() = :20
// lastDispatched = :10 → :20 > :10 → fires
$result = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:42');
$result = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:42');
expect($result)->toBeTrue();
});
it('does not double-dispatch docker cleanup within same cron window', function () {
// First dispatch at :10
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$first = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:99');
$first = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
expect($first)->toBeTrue();
// Second run at :11 — should NOT dispatch (previousDue=:10, lastDispatched=:10)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 11, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$second = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:99');
$second = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
expect($second)->toBeFalse();
});
it('seeds cache with previousDue when not due on first run', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:1');
expect($result)->toBeFalse();
// Verify cache was seeded with previousDue (02:00 today)
$cached = Cache::get('test-seed:1');
expect($cached)->not->toBeNull();
expect(Carbon::parse($cached)->format('H:i'))->toBe('02:00');
});
it('catches next occurrence after cache was seeded on non-due first run', function () {
// Step 1: 10:00 — not due, but seeds cache with previousDue (02:00 today)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeFalse();
// Step 2: Next day at 02:03 — delayed 3 minutes past cron.
// previousDue = 02:00 Mar 1, lastDispatched = 02:00 Feb 28 → fires
Carbon::setTestNow(Carbon::create(2026, 3, 1, 2, 3, 0, 'UTC'));
expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeTrue();
});
it('cache survives 29 days with static 30-day TTL', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
shouldRunCronNow('0 2 * * *', 'UTC', 'test-ttl:static');
expect(Cache::get('test-ttl:static'))->not->toBeNull();
// 29 days later — cache (30-day TTL) should still exist
Carbon::setTestNow(Carbon::create(2026, 3, 29, 0, 0, 0, 'UTC'));
expect(Cache::get('test-ttl:static'))->not->toBeNull();
});
it('respects server timezone for cron evaluation', function () {
// UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC)
Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400);
// Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT
// That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire
$resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6');
expect($resultSingapore)->toBeTrue();
// Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → new window → should fire
expect(shouldRunCronNow('0 6 * * *', 'Asia/Singapore', 'test-backup:6'))->toBeTrue();
// Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire
$resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7');
expect($resultUtc)->toBeFalse();
// Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28, already dispatched → should NOT fire
expect(shouldRunCronNow('0 6 * * *', 'UTC', 'test-backup:7'))->toBeFalse();
});
it('passes explicit execution time instead of using Carbon::now()', function () {
// Real "now" is irrelevant — we pass an explicit execution time
Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
$executionTime = Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-exec-time:1', $executionTime);
expect($result)->toBeTrue();
});
@@ -1,6 +1,5 @@
<?php
use App\Jobs\ServerManagerJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
@@ -9,94 +8,81 @@ beforeEach(function () {
});
it('catches delayed sentinel restart when job runs past midnight', function () {
// Simulate previous dispatch yesterday at midnight
Cache::put('sentinel-restart:1', Carbon::create(2026, 2, 27, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
// Job runs 3 minutes late at 00:03
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC'));
$job = new ServerManagerJob;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today
// lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires
$result = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:1');
$result = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:1');
expect($result)->toBeTrue();
});
it('catches delayed weekly patch check when job runs past the cron minute', function () {
// Simulate previous dispatch last Sunday at midnight
Cache::put('server-patch-check:1', Carbon::create(2026, 2, 22, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
// This Sunday at 00:02 — job was delayed 2 minutes
// 2026-03-01 is a Sunday
// This Sunday at 00:02 — job was delayed 2 minutes (2026-03-01 is a Sunday)
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
$job = new ServerManagerJob;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$result = $method->invoke($job, '0 0 * * 0', 'UTC', 'server-patch-check:1');
$result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:1');
expect($result)->toBeTrue();
});
it('catches delayed storage check when job runs past the cron minute', function () {
// Simulate previous dispatch yesterday at 23:00
Cache::put('server-storage-check:5', Carbon::create(2026, 2, 27, 23, 0, 0, 'UTC')->toIso8601String(), 86400);
// Today at 23:04 — job was delayed 4 minutes
Carbon::setTestNow(Carbon::create(2026, 2, 28, 23, 4, 0, 'UTC'));
$job = new ServerManagerJob;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$result = $method->invoke($job, '0 23 * * *', 'UTC', 'server-storage-check:5');
$result = shouldRunCronNow('0 23 * * *', 'UTC', 'server-storage-check:5');
expect($result)->toBeTrue();
});
it('seeds cache on non-due first run so weekly catch-up works', function () {
// Wednesday at 10:00 — weekly cron (Sunday 00:00) is not due
Carbon::setTestNow(Carbon::create(2026, 2, 25, 10, 0, 0, 'UTC'));
$result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
expect($result)->toBeFalse();
// Verify cache was seeded
expect(Cache::get('server-patch-check:seed-test'))->not->toBeNull();
// Next Sunday at 00:02 — delayed 2 minutes past cron
// Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 22 → fires
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
$result2 = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
expect($result2)->toBeTrue();
});
it('daily cron fires after cache seed even when delayed past the minute', function () {
// Step 1: 15:00 — not due for midnight cron, but seeds cache
Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
$result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
expect($result1)->toBeFalse();
// Step 2: Next day at 00:05 — delayed 5 minutes past midnight
// Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 28 00:00 → fires
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 5, 0, 'UTC'));
$result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
expect($result2)->toBeTrue();
});
it('does not double-dispatch within same cron window', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC'));
$job = new ServerManagerJob;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$first = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:10');
$first = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
expect($first)->toBeTrue();
// Next minute — should NOT dispatch again
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$second = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:10');
$second = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
expect($second)->toBeFalse();
});
+379
View File
@@ -0,0 +1,379 @@
<?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\Server;
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\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake();
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createTestApplication($context): Application
{
return Application::factory()->create([
'environment_id' => $context->environment->id,
]);
}
function createTestDatabase($context): StandalonePostgresql
{
return StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
]);
}
// ──────────────────────────────────────────────────────────────
// Application Storage Endpoints
// ──────────────────────────────────────────────────────────────
describe('GET /api/v1/applications/{uuid}/storages', function () {
test('lists storages for an application', function () {
$app = createTestApplication($this);
LocalPersistentVolume::create([
'name' => $app->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/applications/{$app->uuid}/storages");
$response->assertStatus(200);
$response->assertJsonCount(1, 'persistent_storages');
$response->assertJsonCount(0, 'file_storages');
});
test('returns 404 for non-existent application', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/applications/non-existent-uuid/storages');
$response->assertStatus(404);
});
});
describe('POST /api/v1/applications/{uuid}/storages', function () {
test('creates a persistent storage', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'persistent',
'name' => 'my-volume',
'mount_path' => '/data',
]);
$response->assertStatus(201);
$vol = LocalPersistentVolume::where('resource_id', $app->id)
->where('resource_type', $app->getMorphClass())
->first();
expect($vol)->not->toBeNull();
expect($vol->name)->toBe($app->uuid.'-my-volume');
expect($vol->mount_path)->toBe('/data');
expect($vol->uuid)->not->toBeNull();
});
test('creates a file storage', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'mount_path' => '/app/config.json',
'content' => '{"key": "value"}',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->first();
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/app/config.json');
expect($vol->is_directory)->toBeFalse();
});
test('rejects persistent storage without name', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'persistent',
'mount_path' => '/data',
]);
$response->assertStatus(422);
});
test('rejects invalid type-specific fields', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'persistent',
'name' => 'vol',
'mount_path' => '/data',
'content' => 'should not be here',
]);
$response->assertStatus(422);
});
});
describe('PATCH /api/v1/applications/{uuid}/storages', function () {
test('updates a persistent storage by uuid', function () {
$app = createTestApplication($this);
$vol = LocalPersistentVolume::create([
'name' => $app->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
'uuid' => $vol->uuid,
'type' => 'persistent',
'mount_path' => '/new-data',
]);
$response->assertStatus(200);
expect($vol->fresh()->mount_path)->toBe('/new-data');
});
test('updates a persistent storage by id (backwards compat)', function () {
$app = createTestApplication($this);
$vol = LocalPersistentVolume::create([
'name' => $app->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
'id' => $vol->id,
'type' => 'persistent',
'mount_path' => '/updated',
]);
$response->assertStatus(200);
expect($vol->fresh()->mount_path)->toBe('/updated');
});
test('returns 422 when neither uuid nor id is provided', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'persistent',
'mount_path' => '/data',
]);
$response->assertStatus(422);
});
});
describe('DELETE /api/v1/applications/{uuid}/storages/{storage_uuid}', function () {
test('deletes a persistent storage', function () {
$app = createTestApplication($this);
$vol = LocalPersistentVolume::create([
'name' => $app->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->deleteJson("/api/v1/applications/{$app->uuid}/storages/{$vol->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Storage deleted.']);
expect(LocalPersistentVolume::find($vol->id))->toBeNull();
});
test('finds file storage without type param and calls deleteStorageOnServer', function () {
$app = createTestApplication($this);
$vol = LocalFileVolume::create([
'fs_path' => '/tmp/test',
'mount_path' => '/app/config.json',
'content' => '{}',
'is_directory' => false,
'resource_id' => $app->id,
'resource_type' => get_class($app),
]);
// Verify the storage is found via fileStorages (not persistentStorages)
$freshApp = Application::find($app->id);
expect($freshApp->persistentStorages->where('uuid', $vol->uuid)->first())->toBeNull();
expect($freshApp->fileStorages->where('uuid', $vol->uuid)->first())->not->toBeNull();
expect($vol)->toBeInstanceOf(LocalFileVolume::class);
});
test('returns 404 for non-existent storage', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->deleteJson("/api/v1/applications/{$app->uuid}/storages/non-existent");
$response->assertStatus(404);
});
});
// ──────────────────────────────────────────────────────────────
// Database Storage Endpoints
// ──────────────────────────────────────────────────────────────
describe('GET /api/v1/databases/{uuid}/storages', function () {
test('lists storages for a database', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/databases/{$db->uuid}/storages");
$response->assertStatus(200);
$response->assertJsonStructure(['persistent_storages', 'file_storages']);
// Database auto-creates a default persistent volume
$response->assertJsonCount(1, 'persistent_storages');
});
test('returns 404 for non-existent database', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/databases/non-existent-uuid/storages');
$response->assertStatus(404);
});
});
describe('POST /api/v1/databases/{uuid}/storages', function () {
test('creates a persistent storage for a database', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'persistent',
'name' => 'extra-data',
'mount_path' => '/extra',
]);
$response->assertStatus(201);
$vol = LocalPersistentVolume::where('name', $db->uuid.'-extra-data')->first();
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/extra');
});
});
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
test('updates a persistent storage by uuid', function () {
$db = createTestDatabase($this);
$vol = LocalPersistentVolume::create([
'name' => $db->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $db->id,
'resource_type' => $db->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$db->uuid}/storages", [
'uuid' => $vol->uuid,
'type' => 'persistent',
'mount_path' => '/updated',
]);
$response->assertStatus(200);
expect($vol->fresh()->mount_path)->toBe('/updated');
});
});
describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () {
test('deletes a persistent storage', function () {
$db = createTestDatabase($this);
$vol = LocalPersistentVolume::create([
'name' => $db->uuid.'-test-vol',
'mount_path' => '/extra',
'resource_id' => $db->id,
'resource_type' => $db->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}");
$response->assertStatus(200);
expect(LocalPersistentVolume::find($vol->id))->toBeNull();
});
});
@@ -88,11 +88,11 @@ it('filters out null environment variables from nixpacks build command', functio
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
// Verify that only valid environment variables are included
expect($envArgs)->toContain('--env VALID_VAR=valid_value');
expect($envArgs)->toContain('--env ANOTHER_VALID_VAR=another_value');
expect($envArgs)->toContain('--env COOLIFY_FQDN=example.com');
expect($envArgs)->toContain('--env SOURCE_COMMIT=abc123');
// Verify that only valid environment variables are included (values are now single-quote escaped)
expect($envArgs)->toContain("--env 'VALID_VAR=valid_value'");
expect($envArgs)->toContain("--env 'ANOTHER_VALID_VAR=another_value'");
expect($envArgs)->toContain("--env 'COOLIFY_FQDN=example.com'");
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
// Verify that null and empty environment variables are filtered out
expect($envArgs)->not->toContain('NULL_VAR');
@@ -102,7 +102,7 @@ it('filters out null environment variables from nixpacks build command', functio
// Verify no environment variables end with just '=' (which indicates null/empty value)
expect($envArgs)->not->toMatch('/--env [A-Z_]+=$/');
expect($envArgs)->not->toMatch('/--env [A-Z_]+= /');
expect($envArgs)->not->toMatch("/--env '[A-Z_]+='$/");
});
it('filters out null environment variables from nixpacks preview deployments', function () {
@@ -164,9 +164,9 @@ it('filters out null environment variables from nixpacks preview deployments', f
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
// Verify that only valid environment variables are included
expect($envArgs)->toContain('--env PREVIEW_VAR=preview_value');
expect($envArgs)->toContain('--env COOLIFY_FQDN=preview.example.com');
// Verify that only valid environment variables are included (values are now single-quote escaped)
expect($envArgs)->toContain("--env 'PREVIEW_VAR=preview_value'");
expect($envArgs)->toContain("--env 'COOLIFY_FQDN=preview.example.com'");
// Verify that null environment variables are filtered out
expect($envArgs)->not->toContain('NULL_PREVIEW_VAR');
@@ -335,7 +335,7 @@ it('preserves environment variables with zero values', function () {
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
// Verify that zero and false string values are preserved
expect($envArgs)->toContain('--env ZERO_VALUE=0');
expect($envArgs)->toContain('--env FALSE_VALUE=false');
// Verify that zero and false string values are preserved (values are now single-quote escaped)
expect($envArgs)->toContain("--env 'ZERO_VALUE=0'");
expect($envArgs)->toContain("--env 'FALSE_VALUE=false'");
});
+57
View File
@@ -0,0 +1,57 @@
<?php
it('wraps a simple value in single quotes', function () {
expect(escapeShellValue('hello'))->toBe("'hello'");
});
it('escapes single quotes in the value', function () {
expect(escapeShellValue("it's"))->toBe("'it'\\''s'");
});
it('handles empty string', function () {
expect(escapeShellValue(''))->toBe("''");
});
it('preserves && in a single-quoted value', function () {
$result = escapeShellValue('npx prisma generate && npm run build');
expect($result)->toBe("'npx prisma generate && npm run build'");
});
it('preserves special shell characters in value', function () {
$result = escapeShellValue('echo $HOME; rm -rf /');
expect($result)->toBe("'echo \$HOME; rm -rf /'");
});
it('handles value with double quotes', function () {
$result = escapeShellValue('say "hello"');
expect($result)->toBe("'say \"hello\"'");
});
it('produces correct output when passed through executeInDocker', function () {
// Simulate the exact issue from GitHub #9042:
// NIXPACKS_BUILD_CMD with chained && commands
$envValue = 'npx prisma generate && npx prisma db push && npm run build';
$escapedEnv = '--env '.escapeShellValue("NIXPACKS_BUILD_CMD={$envValue}");
$command = "nixpacks plan -f json {$escapedEnv} /app";
$dockerCmd = executeInDocker('test-container', $command);
// The && must NOT appear unquoted at the bash -c level
// The full docker command should properly nest the quoting
expect($dockerCmd)->toContain('NIXPACKS_BUILD_CMD=npx prisma generate && npx prisma db push && npm run build');
// Verify it's wrapped in docker exec bash -c
expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
expect($dockerCmd)->toEndWith("'");
});
it('produces correct output for build-cmd with chained commands through executeInDocker', function () {
$buildCmd = 'npx prisma generate && npm run build';
$escapedCmd = escapeShellValue($buildCmd);
$command = "nixpacks plan -f json --build-cmd {$escapedCmd} /app";
$dockerCmd = executeInDocker('test-container', $command);
// The build command value must remain intact inside the quoting
expect($dockerCmd)->toContain('npx prisma generate && npm run build');
expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
});