Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai committed 2026-07-11 10:06:49 +02:00
commit 772093928f
241 files changed
+22765 -1087

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
<?php
use App\Models\Server;
it('normalizes requested log line counts', function () {
if (! function_exists('normalizeLogLines')) {
expect(function_exists('normalizeLogLines'))->toBeTrue();
return;
}
expect(normalizeLogLines(null))->toBe(100)
->and(normalizeLogLines(''))->toBe(100)
->and(normalizeLogLines('abc'))->toBe(100)
->and(normalizeLogLines('0'))->toBe(100)
->and(normalizeLogLines('-5'))->toBe(100)
->and(normalizeLogLines('50'))->toBe(50)
->and(normalizeLogLines('50000'))->toBe(10000);
});
it('parses show_timestamps query values as booleans', function () {
if (! function_exists('parseLogTimestampFlag')) {
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
return;
}
expect(parseLogTimestampFlag('true'))->toBeTrue()
->and(parseLogTimestampFlag('1'))->toBeTrue()
->and(parseLogTimestampFlag(true))->toBeTrue()
->and(parseLogTimestampFlag('false'))->toBeFalse()
->and(parseLogTimestampFlag('0'))->toBeFalse()
->and(parseLogTimestampFlag(false))->toBeFalse()
->and(parseLogTimestampFlag('not-a-bool'))->toBeFalse()
->and(parseLogTimestampFlag(null))->toBeFalse();
});
it('builds docker log commands with options before an escaped container id', function () {
if (! function_exists('buildContainerLogsCommand')) {
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
return;
}
$server = new Server;
$server->settings = ['is_swarm_manager' => false];
expect(buildContainerLogsCommand($server, 'container-1', 25, true))
->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1");
});
it('builds swarm service log commands with options before an escaped service id', function () {
if (! function_exists('buildContainerLogsCommand')) {
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
return;
}
$server = new Server;
$server->settings = ['is_swarm_manager' => true];
expect(buildContainerLogsCommand($server, "service'name", 25, true))
->toBe("docker service logs -n 25 --timestamps 'service'\\''name' 2>&1");
});
it('filters service sub containers in PHP instead of using user input in shell filters', function () {
if (! function_exists('filterServiceSubContainersByName')) {
expect(function_exists('filterServiceSubContainersByName'))->toBeTrue();
return;
}
$containers = collect([
['ID' => 'first', 'Labels' => 'coolify.serviceId=10,coolify.name=app-service-uuid,coolify.type=service'],
['ID' => 'second', 'Labels' => 'coolify.serviceId=10,coolify.name=db-service-uuid,coolify.type=service'],
['ID' => 'third', 'Labels' => ['coolify.name' => 'app-service-uuid']],
]);
expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all())
->toBe(['first', 'third']);
});
it('does not interpolate the requested service name into the docker ps shell command', function () {
$source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php');
expect($source)->not->toContain('coolify.name={$name}');
});
@@ -0,0 +1,66 @@
<?php
it('allows selecting Vultr in cloud provider token forms', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
$component = file_get_contents(__DIR__.'/../../app/Livewire/Security/CloudProviderTokenForm.php');
expect($view)->toContain('<x-forms.select required id="provider" label="Provider" wire:model.live="provider">')
->and($view)->toContain('<option value="vultr">Vultr</option>')
->and($view)->toContain('<option value="digitalocean">DigitalOcean</option>')
->and(substr_count($view, 'Open Account → API Access.'))->toBe(2)
->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(2)
->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()')
->and($view)->not->toContain('<x-forms.select required id="provider" label="Provider" disabled>')
->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr'");
});
it('keeps provider affiliate links on server provider views', function () {
$tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
$hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php');
$vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php');
$digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php');
expect($tokenFormView)
->not->toContain('https://coolify.io/hetzner')
->not->toContain('https://coolify.io/vultr')
->and($hetznerView)->toContain('https://coolify.io/hetzner')
->and($vultrView)->toContain('https://coolify.io/vultr')
->and($digitalOceanView)->toContain('https://coolify.io/digitalocean');
});
it('uses a provider dropdown and modal forms on cloud provider tokens page', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php');
expect($view)->toContain('+ Add')
->and($view)->toContain('<div class="flex items-center gap-2">')
->and($view)->toContain('<div class="grid gap-4 lg:grid-cols-2">')
->and($view)->toContain('class="coolbox group"')
->and($view)->toContain('<div class="box-title">')
->and($view)->toContain('<div class="box-description">')
->and($view)->toContain('<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">')
->and($view)->toContain('Add Hetzner Token')
->and($view)->toContain('Add DigitalOcean Token')
->and($view)->toContain('Add Vultr Token')
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner"')
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"')
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"')
->and($view)->not->toContain('<h3>New Token</h3>')
->and($view)->not->toContain(':modal_mode="false"')
->and($view)->not->toContain('wire:click="validateToken')
->and($view)->not->toContain('submitAction="deleteToken')
->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}');
});
it('shows explicit loading spinners and disables cloud token action buttons while requests run', function () {
$tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
$tokenShowView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token/show.blade.php');
$serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php');
expect(substr_count($tokenFormView, 'wire:loading.attr="disabled" wire:target="addToken"'))->toBe(2)
->and(substr_count($tokenFormView, '<x-loading-on-button wire:loading wire:target="addToken" />'))->toBe(2)
->and($tokenShowView)->toContain('wire:loading.attr="disabled" wire:target="validateToken"')
->and($tokenShowView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />')
->and($serverTokenView)->toContain('wire:loading.attr="disabled"')
->and($serverTokenView)->toContain('wire:target="validateToken"')
->and($serverTokenView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />');
});
@@ -66,12 +66,16 @@ it('detects redeploy-only domain changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['fqdn' => 'https://new.example.com']);
$domains = 'https://new.example.com,https://another.example.com';
$application->update(['fqdn' => $domains]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'Domains');
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
->and($change)->not->toBeNull()
->and($change['expandable'])->toBeTrue()
->and($change['new_full_value'])->toBe($domains);
});
it('detects environment variable value changes without exposing secret values', function () {
@@ -0,0 +1,46 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('deletes the DigitalOcean droplet when requested', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
]);
$team = Team::create([
'name' => 'Test Team',
'personal_team' => false,
]);
$token = CloudProviderToken::factory()->create([
'team_id' => $team->id,
'provider' => 'digitalocean',
'token' => 'test-digitalocean-token',
]);
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => 987,
]);
DeleteServer::run(
serverId: $server->id,
deleteFromDigitalOcean: true,
digitalOceanDropletId: 987,
cloudProviderTokenId: $token->id,
teamId: $team->id,
);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/987');
});
+34
View File
@@ -0,0 +1,34 @@
<?php
it('renders the official DigitalOcean logo path', function () {
$contents = file_get_contents(__DIR__.'/../../resources/views/components/digital-ocean-icon.blade.php');
expect($contents)
->toContain('viewBox="0 0 24 24"')
->toContain('M12.04 0C5.408-.02.005 5.37.005 11.992h4.638')
->toContain('DigitalOcean');
});
it('does not use the old custom DigitalOcean icon in Blade views', function () {
$oldIconPaths = [
'M100 42c31.5 0 57 25.5 57 57s-25.5 57-57 57H72v-36h28c11.6 0 21-9.4 21-21s-9.4-21-21-21-21 9.4-21 21H43c0-31.5 25.5-57 57-57z',
'M43 120h36v36H43zM79 156h28v28H79zM43 156h28v28H43z',
];
$bladeFiles = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(__DIR__.'/../../resources/views')
);
foreach ($bladeFiles as $bladeFile) {
if (! $bladeFile->isFile() || $bladeFile->getExtension() !== 'php') {
continue;
}
$contents = file_get_contents($bladeFile->getPathname());
foreach ($oldIconPaths as $oldIconPath) {
expect($contents)
->not->toContain($oldIconPath, $bladeFile->getPathname().' contains the old DigitalOcean icon.');
}
}
});
@@ -0,0 +1,59 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function createDigitalOceanServerForStateTest(string $status = 'active'): Server
{
$team = Team::create([
'name' => 'Test Team',
'personal_team' => false,
]);
$token = CloudProviderToken::create([
'team_id' => $team->id,
'provider' => 'digitalocean',
'token' => 'test-digitalocean-token',
'name' => 'DigitalOcean',
]);
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
return Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => 987,
'digitalocean_droplet_status' => $status,
]);
}
it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () {
$server = createDigitalOceanServerForStateTest();
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response(['message' => 'not found'], 404),
]);
expect($server->refreshDigitalOceanState())->toBe('deleted');
expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted');
});
it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () {
$server = createDigitalOceanServerForStateTest();
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response(['message' => 'server error'], 500),
]);
expect(fn () => $server->refreshDigitalOceanState())
->toThrow(Exception::class);
expect($server->fresh()->digitalocean_droplet_status)->toBe('active');
});
+183
View File
@@ -0,0 +1,183 @@
<?php
use App\Services\DigitalOceanService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('fetches paginated regions from DigitalOcean', function () {
Http::fake([
'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([
'regions' => [
['slug' => 'nyc1', 'name' => 'New York 1', 'available' => true],
],
'links' => ['pages' => ['next' => 'https://api.digitalocean.com/v2/regions?page=2&per_page=50']],
]),
'https://api.digitalocean.com/v2/regions?page=2&per_page=50' => Http::response([
'regions' => [
['slug' => 'ams3', 'name' => 'Amsterdam 3', 'available' => true],
],
'links' => ['pages' => []],
]),
]);
$regions = (new DigitalOceanService('test-token'))->getRegions();
expect($regions)->toHaveCount(2)
->and($regions[0]['slug'])->toBe('nyc1')
->and($regions[1]['slug'])->toBe('ams3');
});
it('creates a droplet with the selected SSH keys and options', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => [
'id' => 987,
'name' => 'coolify-test',
'status' => 'new',
'networks' => [
'v4' => [
['ip_address' => '203.0.113.10', 'type' => 'public'],
],
],
],
], 202),
]);
$droplet = (new DigitalOceanService('test-token'))->createDroplet([
'name' => 'coolify-test',
'region' => 'nyc1',
'size' => 's-1vcpu-1gb',
'image' => 'ubuntu-24-04-x64',
'ssh_keys' => [123],
'ipv6' => true,
'monitoring' => true,
'user_data' => '#cloud-config',
]);
expect($droplet['id'])->toBe(987);
Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets'
&& $request['ssh_keys'] === [123]
&& $request['ipv6'] === true
&& $request['monitoring'] === true
&& $request['user_data'] === '#cloud-config');
});
it('extracts a public IPv4 address before falling back to IPv6', function () {
$droplet = [
'networks' => [
'v4' => [
['ip_address' => '10.0.0.2', 'type' => 'private'],
['ip_address' => '203.0.113.10', 'type' => 'public'],
],
'v6' => [
['ip_address' => '2001:db8::10', 'type' => 'public'],
],
],
];
$service = new DigitalOceanService('test-token');
expect($service->getPublicIpAddress($droplet, true, true))->toBe('203.0.113.10')
->and($service->getPublicIpAddress($droplet, false, true))->toBe('2001:db8::10');
});
it('waits for DigitalOcean to assign a public IP', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
'droplet' => [
'id' => 987,
'status' => 'active',
'networks' => [
'v4' => [
['ip_address' => '203.0.113.10', 'type' => 'public'],
],
],
],
]),
]);
$service = new DigitalOceanService('test-token');
$droplet = $service->waitForPublicIp([
'id' => 987,
'status' => 'new',
'networks' => ['v4' => []],
], sleepMilliseconds: 0);
expect($service->getPublicIpAddress($droplet))->toBe('203.0.113.10');
});
it('keeps polling when DigitalOcean assigns a public IP slowly', function () {
$polls = 0;
Http::fake(function () use (&$polls) {
$polls++;
return Http::response([
'droplet' => [
'id' => 987,
'status' => $polls < 10 ? 'new' : 'active',
'networks' => [
'v4' => $polls < 10 ? [] : [
['ip_address' => '203.0.113.10', 'type' => 'public'],
],
],
],
]);
});
$service = new DigitalOceanService('test-token');
$droplet = $service->waitForPublicIp([
'id' => 987,
'status' => 'new',
'networks' => ['v4' => []],
], sleepMilliseconds: 0);
expect($service->getPublicIpAddress($droplet))->toBe('203.0.113.10')
->and($polls)->toBe(10);
});
it('finds a droplet by public IP', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets*' => Http::response([
'droplets' => [
[
'id' => 987,
'networks' => [
'v4' => [
['ip_address' => '203.0.113.10', 'type' => 'public'],
],
'v6' => [
['ip_address' => '2001:db8::10', 'type' => 'public'],
],
],
],
],
'links' => ['pages' => []],
]),
]);
$service = new DigitalOceanService('test-token');
expect($service->findDropletByIp('203.0.113.10')['id'])->toBe(987)
->and($service->findDropletByIp('2001:db8::10')['id'])->toBe(987)
->and($service->findDropletByIp('198.51.100.1'))->toBeNull();
});
it('powers on and deletes a DigitalOcean droplet', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets/987/actions' => Http::response([
'action' => ['id' => 111, 'type' => 'power_on'],
], 201),
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
]);
$service = new DigitalOceanService('test-token');
expect($service->powerOnDroplet(987)['type'])->toBe('power_on');
$service->deleteDroplet(987);
Http::assertSentCount(2);
});
+16
View File
@@ -0,0 +1,16 @@
<?php
it('normalizes plus and dotted email identity variants', function () {
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->toBe('kevinmcfadden@btinternet.com');
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com');
});
it('returns null for blank or malformed email identities', function (?string $email) {
expect(normalize_email_identity($email))->toBeNull();
})->with([
null,
'',
'not-an-email',
'@example.com',
'local@',
]);
+86
View File
@@ -0,0 +1,86 @@
<?php
use App\Models\GithubApp;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class);
it('classifies github hosts', function () {
expect(isGithubDotComHost('https://github.com'))->toBeTrue()
->and(isGheDotComHost('https://octocorp.ghe.com'))->toBeTrue()
->and(isGithubCloudFamilyHost('https://octocorp.ghe.com'))->toBeTrue()
->and(isGithubCloudFamilyHost('https://github.com'))->toBeTrue()
->and(isGithubEnterpriseServerHost('https://github.company.internal'))->toBeTrue()
->and(isGithubEnterpriseServerHost('https://octocorp.ghe.com'))->toBeFalse();
});
it('derives github api urls from html urls', function (string $htmlUrl, string $apiUrl) {
expect(githubApiUrlFromHtmlUrl($htmlUrl))->toBe($apiUrl);
})->with([
'github.com' => ['https://github.com', 'https://api.github.com'],
'ghe.com data residency' => ['https://octocorp.ghe.com', 'https://api.octocorp.ghe.com'],
'github enterprise server' => ['https://github.company.internal', 'https://github.company.internal/api/v3'],
]);
it('rejects malformed urls when deriving an origin', function (string $url) {
expect(fn () => githubUrlOrigin($url))->toThrow(InvalidArgumentException::class);
})->with([
'blank' => [''],
'scheme-less' => ['github.company.internal'],
'malformed' => ['not-a-url'],
]);
it('generates correct install paths for github cloud ghe cloud and ghes', function (array $attributes, string $expectedPrefix) {
$githubApp = new GithubApp;
$githubApp->forceFill(array_merge([
'id' => 123,
'name' => 'Coolify Test App',
'team_id' => 456,
], $attributes));
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$state = $query['state'] ?? null;
expect($installationUrl)->toStartWith($expectedPrefix)
->and($state)->not->toBeEmpty()
->and(Cache::get('github-app-setup-state:'.hash('sha256', $state)))
->toMatchArray([
'action' => 'install',
'github_app_id' => 123,
'team_id' => 456,
]);
})->with([
'github.com' => [
['html_url' => 'https://github.com'],
'https://github.com/apps/coolify-test-app/installations/new?',
],
'ghe.com organization' => [
['html_url' => 'https://octocorp.ghe.com', 'organization' => 'octo-corp'],
'https://octocorp.ghe.com/apps/octo-corp/coolify-test-app/installations/new?',
],
'ghe.com blank organization fallback' => [
['html_url' => 'https://octocorp.ghe.com', 'organization' => null],
'https://octocorp.ghe.com/apps/coolify-test-app/installations/new?',
],
'github enterprise server' => [
['html_url' => 'https://github.company.internal', 'organization' => 'octo-corp'],
'https://github.company.internal/github-apps/coolify-test-app/installations/new?',
],
]);
it('encodes organization path segments in settings links', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'name' => 'coolify-app',
'organization' => 'octo+corp',
'api_url' => 'https://api.octocorp.ghe.com',
'html_url' => 'https://octocorp.ghe.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => 123,
]);
expect(getPermissionsPath($githubApp))->toBe('https://octocorp.ghe.com/organizations/octo%2Bcorp/settings/apps/coolify-app/permissions');
});
@@ -33,6 +33,19 @@ it('ensures GlobalSearch uses redirect route helper', function () {
->toContain('redirectRoute($this, \'project.resource.create\'');
});
it('routes new server quick action to the new server page', function () {
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
expect($globalSearchFile)
->toContain("if (\$type === 'server')")
->toContain("redirectRoute(\$this, 'server.create')");
expect($bladeFile)
->toContain("window.location.href = '/servers/new'")
->not->toContain('@open-create-modal-server.window');
});
it('ensures docker-image item has quickcommand with new image', function () {
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
@@ -142,3 +155,13 @@ it('uses cropped image assets instead of inline wide logos for GlobalSearch data
expect($globalSearchFile)->not->toContain("'logo_html' =>");
});
it('uses rounded yellow plus icons for GlobalSearch creatable actions without logos', function () {
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
expect($bladeFile)
->toContain('rounded-full bg-warning/20 flex items-center justify-center')
->toContain('class="h-6 w-6 text-warning"')
->not->toContain('rounded-lg bg-warning-100 dark:bg-warning-900/40')
->not->toContain('text-warning-600 dark:text-warning-400');
});
+29
View File
@@ -0,0 +1,29 @@
<?php
it('accepts hostnames containing underscores', function () {
// Regression: PHP's FILTER_VALIDATE_URL rejects underscores in the host,
// which blocked valid service domains (e.g. Docker service naming) from
// being saved and getting Let's Encrypt certificates. See issue #10597.
expect(isValidDomainUrl('https://myapp_service.example.com'))->toBeTrue();
expect(isValidDomainUrl('http://my_app.example.com'))->toBeTrue();
expect(isValidDomainUrl('https://a_b_c.example.com/path'))->toBeTrue();
});
it('accepts ordinary domains and URLs', function () {
expect(isValidDomainUrl('https://example.com'))->toBeTrue();
expect(isValidDomainUrl('http://sub.example.com:8080/path?q=1'))->toBeTrue();
expect(isValidDomainUrl('https://example.com/a_b'))->toBeTrue();
});
it('rejects strings that are not valid URLs', function () {
expect(isValidDomainUrl('not a url'))->toBeFalse();
expect(isValidDomainUrl('example.com'))->toBeFalse();
expect(isValidDomainUrl('ht_tp://example.com'))->toBeFalse();
expect(isValidDomainUrl(''))->toBeFalse();
});
it('rejects URLs that do not use HTTP or HTTPS schemes', function () {
expect(isValidDomainUrl('ftp://example.com'))->toBeFalse();
expect(isValidDomainUrl('javascript://example.com'))->toBeFalse();
expect(isValidDomainUrl('data://example.com'))->toBeFalse();
});
@@ -0,0 +1,322 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\DiscordNotificationSettings;
use App\Models\EmailNotificationSettings;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\OauthSetting;
use App\Models\PrivateKey;
use App\Models\PushoverNotificationSettings;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\SlackNotificationSettings;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\TelegramNotificationSettings;
use App\Models\WebhookNotificationSettings;
describe('Sensitive model fields are hidden by default', function () {
test('ServerSetting hides sentinel and logdrain secrets', function () {
$hidden = (new ServerSetting)->getHidden();
expect($hidden)->toContain(
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
);
});
test('Server hides logdrain api keys', function () {
$hidden = (new Server)->getHidden();
expect($hidden)->toContain(
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
);
});
test('Application hides webhook secrets, dockerfile, compose, and labels', function () {
$hidden = (new Application)->getHidden();
expect($hidden)->toContain(
'http_basic_auth_password',
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'manual_webhook_secret_bitbucket',
'manual_webhook_secret_gitea',
'dockerfile',
'docker_compose',
'docker_compose_raw',
'custom_labels',
);
});
test('EnvironmentVariable hides value and real_value', function () {
$hidden = (new EnvironmentVariable)->getHidden();
expect($hidden)->toContain('value', 'real_value');
});
test('SharedEnvironmentVariable hides value', function () {
$hidden = (new SharedEnvironmentVariable)->getHidden();
expect($hidden)->toContain('value');
});
test('Service hides docker_compose and docker_compose_raw', function () {
$hidden = (new Service)->getHidden();
expect($hidden)->toContain('docker_compose', 'docker_compose_raw');
});
test('ApplicationDeploymentQueue hides logs', function () {
$hidden = (new ApplicationDeploymentQueue)->getHidden();
expect($hidden)->toContain('logs');
});
test('LocalFileVolume hides file content', function () {
$hidden = (new LocalFileVolume)->getHidden();
expect($hidden)->toContain('content');
});
test('PrivateKey hides private key material', function () {
$hidden = (new PrivateKey)->getHidden();
expect($hidden)->toContain('private_key');
});
test('CloudProviderToken hides provider token', function () {
$hidden = (new CloudProviderToken)->getHidden();
expect($hidden)->toContain('token');
});
test('CloudInitScript hides script content', function () {
$hidden = (new CloudInitScript)->getHidden();
expect($hidden)->toContain('script');
});
test('S3Storage hides credentials', function () {
$hidden = (new S3Storage)->getHidden();
expect($hidden)->toContain('key', 'secret');
});
test('OauthSetting hides client secret', function () {
$hidden = (new OauthSetting)->getHidden();
expect($hidden)->toContain('client_secret');
});
test('SslCertificate hides private key', function () {
$hidden = (new SslCertificate)->getHidden();
expect($hidden)->toContain('ssl_private_key');
});
test('EmailNotificationSettings hides SMTP and resend secrets', function () {
$hidden = (new EmailNotificationSettings)->getHidden();
expect($hidden)->toContain(
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_username',
'smtp_password',
'resend_api_key',
);
});
test('InstanceSettings hides instance notification secrets', function () {
$hidden = (new InstanceSettings)->getHidden();
expect($hidden)->toContain(
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_username',
'smtp_password',
'resend_api_key',
'sentinel_token',
);
});
test('Webhook-style notification settings hide delivery endpoints', function () {
expect((new DiscordNotificationSettings)->getHidden())->toContain('discord_webhook_url');
expect((new SlackNotificationSettings)->getHidden())->toContain('slack_webhook_url');
expect((new WebhookNotificationSettings)->getHidden())->toContain('webhook_url');
});
test('PushoverNotificationSettings hides credentials', function () {
$hidden = (new PushoverNotificationSettings)->getHidden();
expect($hidden)->toContain('pushover_user_key', 'pushover_api_token');
});
test('TelegramNotificationSettings hides bot, chat, and thread identifiers', function () {
$hidden = (new TelegramNotificationSettings)->getHidden();
expect($hidden)->toContain(
'telegram_token',
'telegram_chat_id',
'telegram_notifications_deployment_success_thread_id',
'telegram_notifications_deployment_failure_thread_id',
'telegram_notifications_status_change_thread_id',
'telegram_notifications_backup_success_thread_id',
'telegram_notifications_backup_failure_thread_id',
'telegram_notifications_scheduled_task_success_thread_id',
'telegram_notifications_scheduled_task_failure_thread_id',
'telegram_notifications_docker_cleanup_success_thread_id',
'telegram_notifications_docker_cleanup_failure_thread_id',
'telegram_notifications_server_disk_usage_thread_id',
'telegram_notifications_server_reachable_thread_id',
'telegram_notifications_server_unreachable_thread_id',
'telegram_notifications_server_patch_thread_id',
'telegram_notifications_traefik_outdated_thread_id',
);
});
test('TelegramNotificationSettings casts actual docker cleanup thread ids as encrypted', function () {
$casts = (new TelegramNotificationSettings)->getCasts();
expect($casts)->toMatchArray([
'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted',
'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted',
]);
});
test('StandalonePostgresql hides password, init_scripts, db urls', function () {
$hidden = (new StandalonePostgresql)->getHidden();
expect($hidden)->toContain(
'postgres_password',
'init_scripts',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMysql hides passwords and db urls', function () {
$hidden = (new StandaloneMysql)->getHidden();
expect($hidden)->toContain(
'mysql_password',
'mysql_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMariadb hides passwords and db urls', function () {
$hidden = (new StandaloneMariadb)->getHidden();
expect($hidden)->toContain(
'mariadb_password',
'mariadb_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMongodb hides root password and db urls', function () {
$hidden = (new StandaloneMongodb)->getHidden();
expect($hidden)->toContain(
'mongo_initdb_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneRedis hides password and db urls', function () {
$hidden = (new StandaloneRedis)->getHidden();
expect($hidden)->toContain(
'redis_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneClickhouse hides password and db urls', function () {
$hidden = (new StandaloneClickhouse)->getHidden();
expect($hidden)->toContain(
'clickhouse_admin_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneKeydb hides password and db urls', function () {
$hidden = (new StandaloneKeydb)->getHidden();
expect($hidden)->toContain(
'keydb_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneDragonfly hides password and db urls', function () {
$hidden = (new StandaloneDragonfly)->getHidden();
expect($hidden)->toContain(
'dragonfly_password',
'internal_db_url',
'external_db_url',
);
});
});
describe('Sensitive fields are absent from toArray() by default', function () {
test('ServerSetting::toArray() excludes sentinel_custom_url by default', function () {
$setting = new ServerSetting;
$setting->setRawAttributes([
'server_id' => 1,
'sentinel_custom_url' => 'https://secret.example.com',
'wildcard_domain' => 'public.example.com',
], sync: true);
$array = $setting->toArray();
expect($array)->not->toHaveKey('sentinel_custom_url');
expect($array)->toHaveKey('wildcard_domain');
});
test('ServerSetting::toArray() includes sentinel_custom_url after makeVisible', function () {
$setting = new ServerSetting;
$setting->setRawAttributes([
'server_id' => 1,
'sentinel_custom_url' => 'https://secret.example.com',
], sync: true);
$setting->makeVisible(['sentinel_custom_url']);
$array = $setting->toArray();
expect($array['sentinel_custom_url'])->toBe('https://secret.example.com');
});
});
+31
View File
@@ -127,6 +127,37 @@ describe('shouldSkipDueToBackoff', function () {
});
describe('ServerConnectionCheckJob unreachable_count', function () {
it('marks Vultr servers unreachable when provider status is unavailable', function () {
Event::fake([ServerReachabilityChanged::class]);
$settings = Mockery::mock();
$settings->is_reachable = true;
$settings->force_disabled = false;
$settings->shouldReceive('update')
->with(['is_reachable' => false, 'is_usable' => false])
->once();
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->andReturnUsing(fn (string $key) => match ($key) {
'settings' => $settings,
'unreachable_notification_sent' => false,
'vultr_instance_id' => 'instance-1',
'cloudProviderToken' => (object) ['token' => 'test-token'],
'id' => 1,
'name' => 'test-server',
'unreachable_count' => 1,
default => null,
});
$server->shouldReceive('refreshVultrState')->once()->andReturn('stopped');
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$server->uuid = 'server-uuid';
$job = new ServerConnectionCheckJob($server);
$job->handle();
});
it('increments unreachable_count on timeout', function () {
Event::fake([ServerReachabilityChanged::class]);
+19
View File
@@ -0,0 +1,19 @@
<?php
test('server header renders subtitle before status badges', function () {
$navbar = file_get_contents(__DIR__.'/../../resources/views/livewire/server/navbar.blade.php');
$titlePosition = strpos($navbar, '<h1>Server</h1>');
$subtitlePosition = strpos($navbar, 'data-testid="server-subtitle"');
$statusPosition = strpos($navbar, 'data-testid="server-status-summary"');
expect($navbar)->not->toContain('class="subtitle"')
->not->toContain('text-neutral-300')
->toContain('text-neutral-600 dark:text-neutral-400');
expect($titlePosition)->not->toBeFalse()
->and($subtitlePosition)->not->toBeFalse()
->and($statusPosition)->not->toBeFalse()
->and($titlePosition)->toBeLessThan($subtitlePosition)
->and($subtitlePosition)->toBeLessThan($statusPosition);
});
+4
View File
@@ -187,3 +187,7 @@ it('normalizes application domain scheme and host without lowercasing path query
expect(ValidationPatterns::normalizeApplicationDomains($domains))
->toBe('https://example.com/MixedCase/Path?Token=ABC#Fragment,http://sub.example.com/Api/V1');
});
it('validates application domains with underscores in the hostname', function () {
expect(ValidationPatterns::validateApplicationDomains('https://myapp_service.example.com'))->toBeEmpty();
});
+118
View File
@@ -0,0 +1,118 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
$this->team = Team::factory()->create();
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'team_id' => $this->team->id,
'name' => 'Test Private Key',
'description' => 'Test private key',
'private_key' => vultrDeleteTestPrivateKey(),
]);
$this->vultrToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'vultr',
'token' => 'test-vultr-token',
'name' => 'Test Vultr Token',
]);
});
function vultrDeleteTestPrivateKey(): string
{
return <<<'KEY'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----
KEY;
}
it('deletes a Vultr instance when requested', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204),
]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-1',
]);
$server->delete();
DeleteServer::run(
serverId: $server->id,
cloudProviderTokenId: $this->vultrToken->id,
teamId: $this->team->id,
deleteFromVultr: true,
vultrInstanceId: 'instance-1'
);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-1');
expect(Server::withTrashed()->find($server->id))->toBeNull();
});
it('does not use another team Vultr token when deleting an instance', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204),
]);
$otherTeam = Team::factory()->create();
$otherToken = CloudProviderToken::create([
'team_id' => $otherTeam->id,
'provider' => 'vultr',
'token' => 'other-team-vultr-token',
'name' => 'Other Team Vultr Token',
]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-1',
]);
$server->delete();
DeleteServer::run(
serverId: $server->id,
cloudProviderTokenId: $otherToken->id,
teamId: $this->team->id,
deleteFromVultr: true,
vultrInstanceId: 'instance-1'
);
$request = Http::recorded()->first()[0];
expect($request->header('Authorization'))->toBe(['Bearer test-vultr-token']);
});
+166
View File
@@ -0,0 +1,166 @@
<?php
use App\Services\VultrService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Http::preventStrayRequests();
});
it('gets instances from Vultr API', function () {
Http::fake([
'https://api.vultr.com/v2/instances*' => Http::response([
'instances' => [
[
'id' => 'instance-1',
'label' => 'test-server-1',
'status' => 'active',
'main_ip' => '123.45.67.89',
'v6_main_ip' => '2001:db8::1',
],
[
'id' => 'instance-2',
'label' => 'test-server-2',
'status' => 'stopped',
'main_ip' => '98.76.54.32',
'v6_main_ip' => '2001:db8::2',
],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$service = new VultrService('fake-token');
$instances = $service->getInstances();
expect($instances)->toBeArray()
->and($instances)->toHaveCount(2)
->and($instances[0]['id'])->toBe('instance-1')
->and($instances[1]['id'])->toBe('instance-2');
});
it('follows cursor pagination', function () {
Http::fake([
'https://api.vultr.com/v2/regions?per_page=100' => Http::response([
'regions' => [
['id' => 'ewr', 'city' => 'New Jersey'],
],
'meta' => [
'links' => [
'next' => 'https://api.vultr.com/v2/regions?cursor=next-cursor',
],
],
], 200),
'https://api.vultr.com/v2/regions?per_page=100&cursor=next-cursor' => Http::response([
'regions' => [
['id' => 'ams', 'city' => 'Amsterdam'],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$service = new VultrService('fake-token');
$regions = $service->getRegions();
expect($regions)->toHaveCount(2)
->and($regions[0]['id'])->toBe('ewr')
->and($regions[1]['id'])->toBe('ams');
});
it('base64 encodes user data when creating an instance', function () {
Http::fake([
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'main_ip' => '123.45.67.89',
'status' => 'pending',
],
], 202),
]);
$service = new VultrService('fake-token');
$service->createInstance([
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'user_data' => "#cloud-config\npackages:\n - curl",
]);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.vultr.com/v2/instances'
&& $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl");
});
});
it('waits for Vultr to replace placeholder public IP', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'main_ip' => '123.45.67.89',
'status' => 'active',
],
], 200),
]);
$service = new VultrService('fake-token');
$instance = $service->waitForPublicIp([
'id' => 'instance-1',
'main_ip' => '0.0.0.0',
'status' => 'pending',
], sleepMilliseconds: 0);
expect($service->getPublicIp($instance))->toBe('123.45.67.89');
});
it('finds an instance by IPv4 or IPv6', function () {
Http::fake([
'https://api.vultr.com/v2/instances*' => Http::response([
'instances' => [
[
'id' => 'instance-1',
'main_ip' => '123.45.67.89',
'v6_main_ip' => '2001:db8::1',
],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$service = new VultrService('fake-token');
expect($service->findInstanceByIp('123.45.67.89')['id'])->toBe('instance-1')
->and($service->findInstanceByIp('2001:db8::1')['id'])->toBe('instance-1')
->and($service->findInstanceByIp('1.2.3.4'))->toBeNull();
});
it('handles empty successful responses from Vultr API', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1/start' => Http::response(null, 204),
'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204),
]);
$service = new VultrService('fake-token');
expect($service->startInstance('instance-1'))->toBe([]);
$service->deleteInstance('instance-1');
Http::assertSentCount(2);
});
it('encodes instance IDs in request paths', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance%2F1' => Http::response([
'instance' => ['id' => 'instance/1'],
], 200),
]);
$service = new VultrService('fake-token');
expect($service->getInstance('instance/1')['id'])->toBe('instance/1');
Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance%2F1');
});