mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 14:17:18 +00:00
Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react
This commit is contained in:
commit
772093928f
241 files changed
+22765
-1087
No files matched your search
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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@',
|
||||
]);
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user