Files
coolify/tests/Feature/ServerCanvasTest.php
T

326 lines
15 KiB
PHP

<?php
use App\Livewire\Server\Index as ServerIndex;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ResourceLink;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerCanvas;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cookie;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
Server::flushIdentityMap();
$this->team = Team::factory()->create(['show_boarding' => false]);
$this->admin = User::factory()->create();
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
$this->web = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-server', 'ip' => '203.0.113.20']);
$this->db = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'db-server']);
$this->project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Shop']);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'staging']);
});
function serverCanvasApp(Environment $environment, Server $server, string $name): Application
{
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
return Application::factory()->create([
'name' => $name,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
}
function serverCanvasSession(object $test, User $user, Team $team): object
{
return $test->actingAs($user)->withSession(['currentTeam' => $team])
->withHeader('X-Canvas-Team', (string) $team->id);
}
test('the servers canvas shows every server of the team with its resources and links', function () {
$app = serverCanvasApp($this->environment, $this->web, 'storefront');
$api = serverCanvasApp($this->environment, $this->db, 'orders-api');
$link = ResourceLink::create([
'environment_id' => $this->environment->id,
'source_type' => $app->getMorphClass(), 'source_id' => $app->id,
'target_type' => $api->getMorphClass(), 'target_id' => $api->id,
]);
$response = serverCanvasSession($this, $this->admin, $this->team)
->getJson('/canvas/api/servers/canvas')
->assertOk()
->assertJsonPath('title', 'Servers')
->assertJsonPath('title_link', null)
->assertJsonPath('can_edit', true)
->assertJsonPath('layout', 'server')
->assertJsonPath('positions', [])
->assertJsonPath('links.0.id', $link->uuid);
expect(collect($response->json('zones'))->pluck('name')->all())->toBe(['db-server', 'web-server'])
->and(collect($response->json('nodes'))->mapWithKeys(fn ($node) => [$node['name'] => [$node['zone'], $node['context']]])->all())->toBe([
'storefront' => [$this->web->uuid, 'Shop / staging'],
'orders-api' => [$this->db->uuid, 'Shop / staging'],
])
->and($response->json('nodes.0.project'))->toBe(['uuid' => $this->project->uuid, 'name' => 'Shop'])
->and($response->json('nodes.0.environment'))->toBe(['uuid' => $this->environment->uuid, 'name' => 'staging'])
->and(collect($response->json('nodes'))->pluck('context_link')->unique()->all())->toBe([
route('project.resource.index', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]),
])
->and(collect($response->json('zones'))->pluck('link')->all())->toBe([
route('server.show', ['server_uuid' => $this->db->uuid]),
route('server.show', ['server_uuid' => $this->web->uuid]),
]);
});
test('one server canvas shows only that server, and links that leave it are hidden', function () {
$app = serverCanvasApp($this->environment, $this->web, 'storefront');
$api = serverCanvasApp($this->environment, $this->db, 'orders-api');
ResourceLink::create([
'environment_id' => $this->environment->id,
'source_type' => $app->getMorphClass(), 'source_id' => $app->id,
'target_type' => $api->getMorphClass(), 'target_id' => $api->id,
]);
serverCanvasSession($this, $this->admin, $this->team)
->getJson("/canvas/api/servers/{$this->web->uuid}/canvas")
->assertOk()
->assertJsonPath('title', 'web-server')
->assertJsonPath('title_link', route('server.show', ['server_uuid' => $this->web->uuid]))
->assertJsonCount(1, 'zones')
->assertJsonPath('nodes.0.name', 'storefront')
->assertJsonCount(1, 'nodes')
->assertJsonPath('links', []);
});
test('the server canvas uses the card positions saved on the environment canvas', function () {
$app = serverCanvasApp($this->environment, $this->web, 'storefront');
$api = serverCanvasApp($this->environment, $this->db, 'orders-api');
$this->environment->forceFill(['canvas_positions' => [
$app->uuid => ['x' => 120, 'y' => 40],
$api->uuid => ['x' => 480, 'y' => 40],
'deleted-resource' => ['x' => 0, 'y' => 0],
]])->save();
serverCanvasSession($this, $this->admin, $this->team)
->getJson('/canvas/api/servers/canvas')
->assertOk()
->assertJsonPath('positions', [
$app->uuid => ['x' => 120, 'y' => 40],
$api->uuid => ['x' => 480, 'y' => 40],
]);
serverCanvasSession($this, $this->admin, $this->team)
->getJson("/canvas/api/servers/{$this->web->uuid}/canvas")
->assertOk()
->assertJsonPath('positions', [$app->uuid => ['x' => 120, 'y' => 40]]);
});
test('members see the server canvas without server IPs and cannot edit it', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
serverCanvasApp($this->environment, $this->web, 'storefront');
serverCanvasSession($this, $member, $this->team)
->getJson("/canvas/api/servers/{$this->web->uuid}/canvas")
->assertOk()
->assertJsonPath('can_edit', false)
->assertJsonPath('zones.0.ip', null);
serverCanvasSession($this, $this->admin, $this->team)
->getJson("/canvas/api/servers/{$this->web->uuid}/canvas")
->assertJsonPath('zones.0.ip', '203.0.113.20');
});
test('the server canvas never shows another team servers or resources', function () {
$otherTeam = Team::factory()->create(['show_boarding' => false]);
$outsider = User::factory()->create();
$otherTeam->members()->attach($outsider->id, ['role' => 'owner']);
$this->team->members()->attach($outsider->id, ['role' => 'member']);
serverCanvasApp($this->environment, $this->web, 'storefront');
// Session switched to the other team: this team's server is out of reach.
serverCanvasSession($this, $outsider, $otherTeam)
->getJson("/canvas/api/servers/{$this->web->uuid}/canvas")
->assertNotFound();
$all = serverCanvasSession($this, $outsider, $otherTeam)
->getJson('/canvas/api/servers/canvas')
->assertOk();
expect($all->json('zones'))->toBe([])->and($all->json('nodes'))->toBe([]);
serverCanvasSession($this, $outsider, $otherTeam)
->get(route('server.canvas', ['server_uuid' => $this->web->uuid]))
->assertNotFound();
});
test('the canvas pages open for team members', function () {
serverCanvasSession($this, $this->admin, $this->team)
->withoutVite()
->get(route('server.canvas.index'))
->assertOk()
->assertSee('data-endpoint="/servers/canvas"', false);
serverCanvasSession($this, $this->admin, $this->team)
->withoutVite()
->get(route('server.canvas', ['server_uuid' => $this->web->uuid]))
->assertOk()
->assertSee('data-endpoint="/servers/'.$this->web->uuid.'/canvas"', false);
});
test('the servers page reopens the canvas when the user left it on the canvas view', function () {
serverCanvasSession($this, $this->admin, $this->team)
->get(route('server.canvas.index'))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_servers_view", 'canvas');
serverCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_servers_view", 'canvas')
->get(route('server.index'))
->assertRedirect(route('server.canvas.index'));
// The table / grid links on the canvas pick the list view again.
serverCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_servers_view", 'canvas')
->get(route('server.index', ['view' => 'grid']))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_servers_view", 'grid');
serverCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_servers_view", 'grid')
->get(route('server.index'))
->assertOk();
});
test('the environment page reopens the canvas when the user left it on the canvas view', function () {
$parameters = ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid];
serverCanvasSession($this, $this->admin, $this->team)
->get(route('project.environment.canvas', $parameters))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_environment_view", 'canvas');
serverCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_environment_view", 'canvas')
->get(route('project.resource.index', $parameters))
->assertRedirect(route('project.environment.canvas', $parameters));
serverCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_environment_view", 'canvas')
->get(route('project.resource.index', [...$parameters, 'view' => 'table']))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_environment_view", 'table');
});
test('the server resources page reopens the one server canvas when the user left it on the canvas view', function () {
$parameters = ['server_uuid' => $this->web->uuid];
serverCanvasSession($this, $this->admin, $this->team)
->get(route('server.canvas', $parameters))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_server_resources_view", 'canvas');
serverCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_server_resources_view", 'canvas')
->get(route('server.resources', $parameters))
->assertRedirect(route('server.canvas', $parameters));
serverCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_server_resources_view", 'canvas')
->get(route('server.resources', [...$parameters, 'view' => 'list']))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_server_resources_view", 'list');
});
test('the canvas API works with the browser session when the instance API is disabled', function () {
InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => false]);
serverCanvasSession($this, $this->admin, $this->team)
->getJson('/canvas/api/servers/canvas')
->assertOk()
->assertJsonPath('title', 'Servers');
});
test('the remembered view belongs to the user, not to the browser', function () {
$other = User::factory()->create();
$this->team->members()->attach($other->id, ['role' => 'admin']);
serverCanvasSession($this, $other, $this->team)
->withCookie("coolify_{$this->admin->id}_servers_view", 'canvas')
->get(route('server.index'))
->assertOk();
});
test('the list page remembers the table or grid choice and opens it again', function () {
$this->actingAs($this->admin)->withSession(['currentTeam' => $this->team]);
$cookie = "coolify_{$this->admin->id}_servers_view";
Livewire::test(ServerIndex::class)->call('saveListView', 'servers', 'grid');
expect(Cookie::queued($cookie)?->getValue())->toBe('grid');
// Unknown pages and views are ignored.
Cookie::flushQueuedCookies();
Livewire::test(ServerIndex::class)
->call('saveListView', 'servers', 'canvas')
->call('saveListView', 'billing', 'grid');
expect(Cookie::getQueuedCookies())->toBeEmpty();
serverCanvasSession($this, $this->admin, $this->team)
->withCookie($cookie, 'grid')
->get(route('server.index'))
->assertOk()
->assertSee("viewMode: 'grid'", false);
});
test('one server canvas loads the unmanaged containers of that server', function () {
$server = Mockery::mock($this->web)->makePartial();
$server->shouldReceive('loadUnmanagedContainers')->andReturn(collect([
['ID' => 'abc123', 'Names' => 'portainer', 'Image' => 'portainer/portainer-ce', 'State' => 'running', 'Status' => 'Up 2 hours (healthy)'],
['ID' => 'def456', 'Names' => 'old-job', 'Image' => 'busybox', 'State' => 'exited', 'Status' => 'Exited (0) 3 days ago'],
]));
$nodes = app(ServerCanvas::class)->unmanagedContainerNodes($server);
expect($nodes->map(fn ($node) => [$node['id'], $node['type'], $node['name'], $node['status'], $node['subtitle'], $node['zone'], $node['context']])->all())->toBe([
['container:def456', 'container', 'old-job', 'exited', 'busybox', $this->web->uuid, 'Unmanaged containers'],
['container:abc123', 'container', 'portainer', 'running:healthy', 'portainer/portainer-ce', $this->web->uuid, 'Unmanaged containers'],
]);
});
test('the server canvas still loads when the unmanaged containers cannot be read', function () {
$server = Mockery::mock($this->web)->makePartial();
$server->shouldReceive('loadUnmanagedContainers')->andThrow(new RuntimeException('SSH failed'));
expect(app(ServerCanvas::class)->unmanagedContainerNodes($server)->all())->toBe([]);
});
test('only the one server canvas points to the unmanaged containers of its server', function () {
$otherTeam = Team::factory()->create(['show_boarding' => false]);
$foreign = Server::factory()->create(['team_id' => $otherTeam->id]);
serverCanvasSession($this, $this->admin, $this->team)->getJson("/canvas/api/servers/{$this->web->uuid}/canvas")
->assertJsonPath('containers_endpoint', "/servers/{$this->web->uuid}/canvas/containers");
serverCanvasSession($this, $this->admin, $this->team)->getJson('/canvas/api/servers/canvas')
->assertJsonPath('containers_endpoint', null);
serverCanvasSession($this, $this->admin, $this->team)->getJson("/canvas/api/servers/{$this->web->uuid}/canvas/containers")
->assertOk()->assertExactJson(['nodes' => []]);
serverCanvasSession($this, $this->admin, $this->team)->getJson("/canvas/api/servers/{$foreign->uuid}/canvas/containers")
->assertNotFound();
});