Files
coolify/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php
T
Andras Bacsai 165d2f3dc8 fix: harden traffic analytics, terminal errors and Postgres restores
Traffic analytics:
- Reject enabling unless the server runs a Coolify-managed Traefik or
  Caddy proxy, and show the reason in the settings UI
- Save the proxy configuration before the setting so a failure leaves
  both unchanged; allow disabling after the proxy was removed
- Skip the proxy restart when the proxy is stopped and report that the
  config applies on next start
- Preserve the user's own Traefik --accesslog* flags and restore them
  when analytics is disabled
- Rotate the Traefik access log in the sidecar with BusyBox tools
  (copytruncate, 5 gzip rotations)
- Create the access log before starting Sentinel, which opens it once

Terminal:
- Detect and surface WebSocket connection rejections in the browser
  terminal, with shared helpers in terminal-connection.js and
  terminal-utils.js

Database import:
- Restore PostgreSQL backups in a single transaction; SQL replace
  restores go into a temporary database and swap in only on success,
  leaving the current database untouched on failure
2026-09-25 22:58:07 +02:00

187 lines
8.1 KiB
PHP

<?php
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Actions\Server\ConfigureTrafficAnalytics;
use App\Actions\Server\StartSentinel;
use App\Jobs\RestartProxyJob;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
$user = User::factory()->create();
$this->team = $user->teams()->first();
});
it('enables analytics without replacing custom proxy configuration', function () {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn(<<<'YAML'
services:
traefik:
image: traefik:v3.7
env_file:
- .env
command:
- '--providers.docker=true'
YAML);
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->once()->withArgs(
fn (Server $server, string $configuration): bool => str_contains($configuration, 'traefik:v3.7')
&& str_contains($configuration, 'env_file:')
&& str_contains($configuration, '--accesslog=true')
);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
ConfigureTrafficAnalytics::run($server, true);
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue();
Queue::assertPushed(RestartProxyJob::class);
});
it('disables analytics', function () {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->twice()->andReturn("services:\n traefik:\n command: []\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->twice();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
ConfigureTrafficAnalytics::run($server, true);
ConfigureTrafficAnalytics::run($server, false);
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
});
function trafficAnalyticsProxyServer(object $test, string $proxyType, array $proxy = [], bool $analyticsEnabled = false): Server
{
$server = Server::factory()->create(['team_id' => $test->team->id]);
$server->proxy->set('type', $proxyType);
foreach ($proxy as $key => $value) {
$server->proxy->set($key, $value);
}
$server->save();
$server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
$server->settings->save();
return $server->fresh();
}
it('rejects enabling analytics when the server has no traefik or caddy proxy', function (string $proxyType) {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->never();
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->never();
$server = trafficAnalyticsProxyServer($this, $proxyType);
expect(fn () => ConfigureTrafficAnalytics::run($server, true))
->toThrow(RuntimeException::class, 'Traffic analytics needs the Traefik or Caddy proxy.');
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse()
->and($server->isTrafficAnalyticsEnabled())->toBeFalse()
->and($server->fresh()->proxy->get('last_saved_proxy_configuration'))->toBeNull();
Queue::assertNotPushed(RestartProxyJob::class);
})->with(['NONE', 'NGINX']);
it('lets a server without a usable proxy turn analytics off', function () {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->once();
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->never();
$server = trafficAnalyticsProxyServer($this, 'NONE', analyticsEnabled: true);
$server->settings->is_sentinel_enabled = true;
$server->settings->save();
expect(ConfigureTrafficAnalytics::run($server, false))->toBeFalse();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
Queue::assertNotPushed(RestartProxyJob::class);
});
it('keeps the analytics setting and saved proxy configuration unchanged when saving the configuration fails', function (bool $enable) {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->never();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn("services:\n traefik:\n command: []\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturnUsing(function (Server $server, string $configuration) {
$server->proxy->last_saved_settings = 'new-hash';
$server->proxy->last_saved_proxy_configuration = $configuration;
$server->save();
throw new RuntimeException('SSH connection failed.');
});
$server = trafficAnalyticsProxyServer($this, 'TRAEFIK', [
'status' => 'running',
'last_saved_settings' => 'old-hash',
'last_saved_proxy_configuration' => 'old-configuration',
], analyticsEnabled: ! $enable);
expect(fn () => ConfigureTrafficAnalytics::run($server, $enable))
->toThrow(RuntimeException::class, 'SSH connection failed.');
$fresh = $server->fresh();
expect($fresh->isTrafficAnalyticsEnabled())->toBe(! $enable)
->and($server->isTrafficAnalyticsEnabled())->toBe(! $enable)
->and($fresh->proxy->get('last_saved_settings'))->toBe('old-hash')
->and($fresh->proxy->get('last_saved_proxy_configuration'))->toBe('old-configuration');
Queue::assertNotPushed(RestartProxyJob::class);
})->with(['enabling' => true, 'disabling' => false]);
it('keeps the analytics setting unchanged when the proxy configuration is not a mapping', function () {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->never();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn("services:\n traefik:\n command: '--api'\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->never();
$server = trafficAnalyticsProxyServer($this, 'TRAEFIK', ['status' => 'running']);
expect(fn () => ConfigureTrafficAnalytics::run($server, true))->toThrow(Exception::class);
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse()
->and($server->isTrafficAnalyticsEnabled())->toBeFalse();
Queue::assertNotPushed(RestartProxyJob::class);
});
it('saves the configuration without starting a proxy the user stopped', function (array $proxy) {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->once();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn("services:\n traefik:\n command: []\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->once()->withArgs(
fn (Server $server, string $configuration): bool => str_contains($configuration, '--accesslog=true')
);
$server = trafficAnalyticsProxyServer($this, 'TRAEFIK', $proxy);
expect(ConfigureTrafficAnalytics::run($server, true))->toBeFalse();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue()
->and((bool) $server->fresh()->proxy->get('force_stop'))->toBe((bool) ($proxy['force_stop'] ?? false));
Queue::assertNotPushed(RestartProxyJob::class);
})->with([
'force stopped' => [['status' => 'running', 'force_stop' => true]],
'exited' => [['status' => 'exited']],
'stopped' => [['status' => 'stopped']],
]);
it('restarts a running proxy to apply the configuration', function (string $proxyType) {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->once();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn($proxyType === 'CADDY'
? "services:\n caddy:\n volumes: []\n"
: "services:\n traefik:\n command: []\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->once();
$server = trafficAnalyticsProxyServer($this, $proxyType, ['status' => 'running', 'force_stop' => false]);
expect(ConfigureTrafficAnalytics::run($server, true))->toBeTrue();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue();
Queue::assertPushed(RestartProxyJob::class);
})->with(['TRAEFIK', 'CADDY']);