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

This commit is contained in:
Andras Bacsai committed 2026-04-30 11:47:06 +02:00
commit 8e91d627a3
8 files changed
+216 -17

No files matched your search

@@ -4,6 +4,7 @@ use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
@@ -78,11 +79,73 @@ describe('ServerSetting::isValidSentinelToken', function () {
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
});
it('returns false for null sentinel token', function () {
expect(ServerSetting::isValidSentinelToken(null))->toBeFalse();
});
it('rejects the reported PoC payload', function () {
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
});
});
describe('ServerSetting::ensureValidSentinelToken', function () {
it('regenerates empty sentinel token via ensureValidSentinelToken', function () {
$settings = $this->server->settings;
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
$settings->refresh();
$token = $settings->ensureValidSentinelToken();
expect($token)->not->toBeEmpty();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
expect($settings->fresh()->sentinel_token)->toBe($token);
});
it('regenerates token when stored value cannot be decrypted', function () {
$settings = $this->server->settings;
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']);
$settings->refresh();
$token = $settings->ensureValidSentinelToken();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
expect($settings->fresh()->sentinel_token)->toBe($token);
});
it('returns existing valid token without regenerating', function () {
$settings = $this->server->settings;
$original = $settings->sentinel_token;
$token = $settings->ensureValidSentinelToken();
expect($token)->toBe($original);
});
it('throws RuntimeException only when regeneration also fails', function () {
$settings = $this->server->settings;
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
$stub = new class extends ServerSetting
{
protected $table = 'server_settings';
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
{
DB::table('server_settings')->where('id', $this->id)->update([
'sentinel_token' => encrypt('invalid token with spaces!'),
]);
return '';
}
};
$stub->setRawAttributes($settings->fresh()->getAttributes(), true);
$stub->exists = true;
expect(fn () => $stub->ensureValidSentinelToken())
->toThrow(RuntimeException::class, 'Sentinel token invalid after regeneration');
});
});
describe('generated sentinel tokens are valid', function () {
it('generates tokens that pass format validation', function () {
$settings = $this->server->settings;
@@ -92,4 +155,11 @@ describe('generated sentinel tokens are valid', function () {
expect($token)->not->toBeEmpty();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
});
it('returns the same value the cast reads back', function () {
$settings = $this->server->settings;
$returned = $settings->generateSentinelToken(save: true, ignoreEvent: true);
expect($settings->fresh()->sentinel_token)->toBe($returned);
});
});
@@ -0,0 +1,65 @@
<?php
use App\Exceptions\DeploymentException;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\TimeoutExceededException;
use Laravel\Horizon\Contracts\JobRepository;
use Mockery\MockInterface;
function fakeJob(string $uuid): Job
{
$job = Mockery::mock(Job::class)->shouldIgnoreMissing();
$job->shouldReceive('uuid')->andReturn($uuid);
$job->shouldReceive('getJobId')->andReturn($uuid);
return $job;
}
function fireJobFailed(Job $job, Throwable $exception): void
{
event(new JobFailed('redis', $job, $exception));
}
beforeEach(function () {
config(['constants.coolify.self_hosted' => false]);
});
test('scrubs Horizon failed entry for DeploymentException on cloud', function () {
$uuid = 'uuid-deployment-1';
$this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
$mock->shouldReceive('deleteFailed')->once()->with($uuid);
});
fireJobFailed(fakeJob($uuid), new DeploymentException('build failed'));
});
test('scrubs Horizon failed entry for TimeoutExceededException on cloud', function () {
$uuid = 'uuid-timeout-1';
$this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
$mock->shouldReceive('deleteFailed')->once()->with($uuid);
});
fireJobFailed(fakeJob($uuid), new TimeoutExceededException('worker timeout'));
});
test('does not scrub generic exceptions on cloud', function () {
$this->mock(JobRepository::class, function (MockInterface $mock) {
$mock->shouldNotReceive('deleteFailed');
});
fireJobFailed(fakeJob('uuid-generic-1'), new RuntimeException('boom'));
});
test('does not scrub when self-hosted even for filtered exceptions', function () {
config(['constants.coolify.self_hosted' => true]);
$this->mock(JobRepository::class, function (MockInterface $mock) {
$mock->shouldNotReceive('deleteFailed');
});
fireJobFailed(fakeJob('uuid-deployment-2'), new DeploymentException('build failed'));
fireJobFailed(fakeJob('uuid-timeout-2'), new TimeoutExceededException('worker timeout'));
});