Files
coolify/tests/Feature/SharedVariableCanvasTest.php
Andras Bacsai fdd69977a4 feat(canvas): show empty environments as placeholder cards
Environments without resources now appear as placeholder cards on the
project, team projects and server canvases. They sit in the zone that
holds most of their project's cards, or in a new "Empty environments"
zone when there is none. A new EmptyCard component renders them.

- Project canvas: admins can move groups and cards, and the moves are
  saved on their environment canvas. Environments are ordered by id.
- Server canvas: admins can move single cards as well as groups.
- Shared variable canvas: built-in COOLIFY_SERVER_UUID/NAME variables
  and their links are hidden on server scope.
- Main nav: resource menus register through registerMainNavContext()
  with depth levels (#main-nav-context-{depth}). Back goes one level
  lower, and Back at depth 0 returns to the main menu.
- The deploy canvas state, styles and controls are simplified, and the
  related sidebar, navbar and dashboard views are updated.

Tests are added or updated for the canvases and the main nav resource
menus.
2026-10-15 08:46:44 +02:00

357 lines
18 KiB
PHP

<?php
use App\Livewire\SharedVariables\Canvas as SharedVariablesCanvas;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Arr;
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->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-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']);
$this->storefront = sharedVariableCanvasApp($this->environment, $this->server, 'storefront');
$this->token = SharedEnvironmentVariable::create([
'key' => 'API_TOKEN', 'value' => 'secret-value', 'type' => 'team', 'team_id' => $this->team->id,
]);
});
function sharedVariableCanvasApp(Environment $environment, Server $server, string $name): Application
{
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
return Application::factory()->create([
'name' => $name,
'build_pack' => 'dockerimage',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
}
function sharedVariableCanvasEnv(Application $application, string $key, string $value): EnvironmentVariable
{
return EnvironmentVariable::create([
'key' => $key, 'value' => $value,
'resourceable_type' => $application->getMorphClass(), 'resourceable_id' => $application->id,
]);
}
function sharedVariableCanvasEnvs(Application $application)
{
return EnvironmentVariable::query()->where('resourceable_type', $application->getMorphClass())->where('resourceable_id', $application->id);
}
function sharedVariableCanvasAs(object $test, User $user, Team $team): object
{
return $test->actingAs($user)->withSession(['currentTeam' => $team])
->withHeader('X-Canvas-Team', (string) $team->id);
}
test('the team canvas shows the variables, the resources and which resources use them, without values', function () {
sharedVariableCanvasEnv($this->storefront, 'API_TOKEN', '{{team.API_TOKEN}}');
$worker = sharedVariableCanvasApp($this->environment, $this->server, 'worker');
sharedVariableCanvasEnv($worker, 'DATABASE_URL', 'postgres://{{ team.API_TOKEN }}@db');
sharedVariableCanvasEnv($worker, 'OLD', '{{team.REMOVED_KEY}}');
sharedVariableCanvasEnv($worker, 'PROJECT_ONLY', '{{project.API_TOKEN}}');
$response = sharedVariableCanvasAs($this, $this->admin, $this->team)
->getJson('/canvas/api/shared-variables/team/canvas')
->assertOk()
->assertJsonPath('layout', 'connect')
->assertJsonPath('can_edit', true)
->assertJsonPath('title_link', route('shared-variables.team.index'));
$variableId = 'variable:'.$this->token->id;
expect($response->getContent())->not->toContain('secret-value')
->and(collect($response->json('nodes'))->mapWithKeys(fn ($node) => [$node['id'] => [$node['name'], $node['zone']]])->all())->toBe([
$variableId => ['API_TOKEN', 'sources'],
'missing:REMOVED_KEY' => ['REMOVED_KEY', 'sources'],
$this->storefront->uuid => ['storefront', 'resources'],
$worker->uuid => ['worker', 'resources'],
])
->and(collect($response->json('links'))->mapWithKeys(fn ($link) => [$link['id'] => Arr::only($link, ['label', 'removable', 'missing'])])->all())->toBe([
"{$variableId}|{$this->storefront->uuid}" => ['label' => null, 'removable' => true, 'missing' => false],
"{$variableId}|{$worker->uuid}" => ['label' => 'DATABASE_URL', 'removable' => false, 'missing' => false],
"missing:REMOVED_KEY|{$worker->uuid}" => ['label' => 'OLD', 'removable' => true, 'missing' => true],
]);
// Resources carry their project and environment for the canvas filter.
$storefront = collect($response->json('nodes'))->firstWhere('id', $this->storefront->uuid);
expect($storefront['project'])->toBe(['uuid' => $this->project->uuid, 'name' => 'Shop'])
->and($storefront['environment'])->toBe(['uuid' => $this->environment->uuid, 'name' => 'staging']);
});
test('an admin adds a shared variable to a resource from the canvas', function () {
sharedVariableCanvasAs($this, $this->admin, $this->team)
->postJson('/canvas/api/shared-variables/team/canvas/links', [
'source' => 'variable:'.$this->token->id,
'target' => $this->storefront->uuid,
])
->assertCreated()
->assertJsonPath('id', 'variable:'.$this->token->id.'|'.$this->storefront->uuid)
->assertJsonPath('keys', ['API_TOKEN']);
$variables = sharedVariableCanvasEnvs($this->storefront)->get();
expect($variables)->toHaveCount(2)
->and($variables->pluck('value')->unique()->all())->toBe(['{{team.API_TOKEN}}'])
->and($variables->pluck('is_preview')->sort()->values()->all())->toBe([false, true])
->and($variables->firstWhere('is_preview', false)->real_value)->toBe('secret-value');
// Adding it again, or over a variable with the same name, is refused.
sharedVariableCanvasAs($this, $this->admin, $this->team)
->postJson('/canvas/api/shared-variables/team/canvas/links', ['source' => 'variable:'.$this->token->id, 'target' => $this->storefront->uuid])
->assertStatus(422)
->assertJsonPath('message', 'storefront already uses API_TOKEN.');
$other = sharedVariableCanvasApp($this->environment, $this->server, 'other');
sharedVariableCanvasEnv($other, 'API_TOKEN', 'local-value');
sharedVariableCanvasAs($this, $this->admin, $this->team)
->postJson('/canvas/api/shared-variables/team/canvas/links', ['source' => 'variable:'.$this->token->id, 'target' => $other->uuid])
->assertStatus(422)
->assertJsonPath('message', 'other already has a variable named API_TOKEN. Rename or remove it first.');
});
test('removing a link deletes the reference and its preview copy, but not a longer value', function () {
sharedVariableCanvasEnv($this->storefront, 'API_TOKEN', '{{team.API_TOKEN}}');
$linkId = 'variable:'.$this->token->id.'|'.$this->storefront->uuid;
expect(sharedVariableCanvasEnvs($this->storefront)->count())->toBe(2);
sharedVariableCanvasAs($this, $this->admin, $this->team)
->deleteJson('/canvas/api/shared-variables/team/canvas/links/'.rawurlencode($linkId))
->assertOk();
expect(sharedVariableCanvasEnvs($this->storefront)->count())->toBe(0);
sharedVariableCanvasEnv($this->storefront, 'DATABASE_URL', 'postgres://{{team.API_TOKEN}}@db');
sharedVariableCanvasAs($this, $this->admin, $this->team)
->deleteJson('/canvas/api/shared-variables/team/canvas/links/'.rawurlencode($linkId))
->assertStatus(422);
expect(sharedVariableCanvasEnvs($this->storefront)->where('is_preview', false)->pluck('key')->all())->toBe(['DATABASE_URL']);
});
test('a reference to a deleted shared variable can be removed', function () {
sharedVariableCanvasEnv($this->storefront, 'OLD', '{{team.REMOVED_KEY}}');
sharedVariableCanvasAs($this, $this->admin, $this->team)
->deleteJson('/canvas/api/shared-variables/team/canvas/links/'.rawurlencode('missing:REMOVED_KEY|'.$this->storefront->uuid))
->assertOk();
expect(sharedVariableCanvasEnvs($this->storefront)->exists())->toBeFalse();
});
test('a member sees the canvas but cannot add or remove shared variables', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
sharedVariableCanvasEnv($this->storefront, 'API_TOKEN', '{{team.API_TOKEN}}');
sharedVariableCanvasAs($this, $member, $this->team)
->getJson('/canvas/api/shared-variables/team/canvas')
->assertOk()
->assertJsonPath('can_edit', false);
sharedVariableCanvasAs($this, $member, $this->team)
->postJson('/canvas/api/shared-variables/team/canvas/links', ['source' => 'variable:'.$this->token->id, 'target' => $this->storefront->uuid])
->assertForbidden();
sharedVariableCanvasAs($this, $member, $this->team)
->deleteJson('/canvas/api/shared-variables/team/canvas/links/'.rawurlencode('variable:'.$this->token->id.'|'.$this->storefront->uuid))
->assertForbidden();
expect(sharedVariableCanvasEnvs($this->storefront)->count())->toBe(2);
});
test('a project canvas only links project variables to resources of that project', function () {
$projectVariable = SharedEnvironmentVariable::create([
'key' => 'SHOP_NAME', 'value' => 'shop', 'type' => 'project', 'team_id' => $this->team->id, 'project_id' => $this->project->id,
]);
$otherProject = Project::factory()->create(['team_id' => $this->team->id]);
$outside = sharedVariableCanvasApp(Environment::factory()->create(['project_id' => $otherProject->id]), $this->server, 'outside');
$base = "/canvas/api/shared-variables/project/{$this->project->uuid}/canvas";
$response = sharedVariableCanvasAs($this, $this->admin, $this->team)->getJson($base)->assertOk();
expect(collect($response->json('nodes'))->pluck('name')->all())->toBe(['SHOP_NAME', 'storefront']);
sharedVariableCanvasAs($this, $this->admin, $this->team)
->postJson("{$base}/links", ['source' => 'variable:'.$projectVariable->id, 'target' => $outside->uuid])
->assertStatus(422)
->assertJsonValidationErrors('target');
sharedVariableCanvasAs($this, $this->admin, $this->team)
->postJson("{$base}/links", ['source' => 'variable:'.$this->token->id, 'target' => $this->storefront->uuid])
->assertStatus(422)
->assertJsonValidationErrors('source');
sharedVariableCanvasAs($this, $this->admin, $this->team)
->postJson("{$base}/links", ['source' => 'variable:'.$projectVariable->id, 'target' => $this->storefront->uuid])
->assertCreated();
expect(sharedVariableCanvasEnvs($this->storefront)->where('is_preview', false)->first()->value)->toBe('{{project.SHOP_NAME}}');
});
test('a server canvas only shows resources on that server', function () {
$other = Server::factory()->create(['team_id' => $this->team->id]);
sharedVariableCanvasApp($this->environment, $other, 'elsewhere');
$response = sharedVariableCanvasAs($this, $this->admin, $this->team)
->getJson("/canvas/api/shared-variables/server/{$this->server->uuid}/canvas")
->assertOk();
expect(collect($response->json('nodes'))->where('type', '!=', 'variable')->pluck('name')->all())->toBe(['storefront']);
});
test('the server canvas hides the built-in server variables and the references to them', function () {
$this->server->environment_variables()->create(['key' => 'REGION', 'value' => 'eu', 'team_id' => $this->team->id]);
sharedVariableCanvasEnv($this->storefront, 'HOST', '{{server.COOLIFY_SERVER_NAME}}');
sharedVariableCanvasEnv($this->storefront, 'REGION', '{{server.REGION}}');
$response = sharedVariableCanvasAs($this, $this->admin, $this->team)
->getJson("/canvas/api/shared-variables/server/{$this->server->uuid}/canvas")
->assertOk();
// Coolify creates COOLIFY_SERVER_UUID / COOLIFY_SERVER_NAME on every server; they are not shown, not even as deleted.
expect(collect($response->json('nodes'))->where('type', 'variable')->pluck('name')->all())->toBe(['REGION'])
->and(collect($response->json('links'))->pluck('key')->all())->toBe(['REGION']);
});
test('another team cannot open or change the canvas of a scope it does not own', function () {
$otherTeam = Team::factory()->create(['show_boarding' => false]);
$otherTeam->members()->attach($this->admin->id, ['role' => 'owner']);
// The session is switched to the other team after the canvas was opened.
sharedVariableCanvasAs($this, $this->admin, $otherTeam)
->getJson("/canvas/api/shared-variables/project/{$this->project->uuid}/canvas")
->assertNotFound();
sharedVariableCanvasAs($this, $this->admin, $otherTeam)
->postJson('/canvas/api/shared-variables/team/canvas/links', ['source' => 'variable:'.$this->token->id, 'target' => $this->storefront->uuid])
->assertStatus(422);
sharedVariableCanvasAs($this, $this->admin, $otherTeam)
->get(route('shared-variables.project.canvas', ['project_uuid' => $this->project->uuid]))
->assertNotFound();
expect(sharedVariableCanvasEnvs($this->storefront)->exists())->toBeFalse();
});
test('the canvas page opens for every scope', function () {
$routes = [
route('shared-variables.team.canvas'),
route('shared-variables.project.canvas', ['project_uuid' => $this->project->uuid]),
route('shared-variables.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]),
route('shared-variables.server.canvas', ['server_uuid' => $this->server->uuid]),
];
$endpoints = [
'/shared-variables/team/canvas',
"/shared-variables/project/{$this->project->uuid}/canvas",
"/shared-variables/environment/{$this->environment->uuid}/canvas",
"/shared-variables/server/{$this->server->uuid}/canvas",
];
foreach ($routes as $i => $url) {
sharedVariableCanvasAs($this, $this->admin, $this->team)->get($url)
->assertOk()
->assertSee('data-endpoint="'.$endpoints[$i].'"', false);
}
});
test('every shared variable row links to the connect envs canvas with the variable selected', function () {
$projectVariable = SharedEnvironmentVariable::create([
'key' => 'SHOP_NAME', 'value' => 'shop', 'type' => 'project', 'team_id' => $this->team->id, 'project_id' => $this->project->id,
]);
sharedVariableCanvasAs($this, $this->admin, $this->team)->get(route('shared-variables.team.index'))
->assertOk()
->assertSee(route('shared-variables.team.canvas').'?select=variable%3A'.$this->token->id, false);
sharedVariableCanvasAs($this, $this->admin, $this->team)->get(route('shared-variables.project.show', ['project_uuid' => $this->project->uuid]))
->assertOk()
->assertSee(route('shared-variables.project.canvas', ['project_uuid' => $this->project->uuid]).'?select=variable%3A'.$projectVariable->id, false);
});
test('each shared variables page reopens its canvas when the user left it on the canvas view', function (string $scope) {
$parameters = match ($scope) {
'team' => [],
'project' => ['project_uuid' => $this->project->uuid],
'environment' => ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid],
'server' => ['server_uuid' => $this->server->uuid],
};
$listRoute = $scope === 'team' ? 'shared-variables.team.index' : "shared-variables.{$scope}.show";
$cookie = "coolify_{$this->admin->id}_{$scope}_variables_view";
sharedVariableCanvasAs($this, $this->admin, $this->team)
->get(route("shared-variables.{$scope}.canvas", $parameters))
->assertOk()
->assertCookie($cookie, 'canvas');
sharedVariableCanvasAs($this, $this->admin, $this->team)
->withCookie($cookie, 'canvas')
->get(route($listRoute, $parameters))
->assertRedirect(route("shared-variables.{$scope}.canvas", $parameters));
sharedVariableCanvasAs($this, $this->admin, $this->team)
->withCookie($cookie, 'canvas')
->get(route($listRoute, [...$parameters, 'view' => 'list']))
->assertOk()
->assertCookie($cookie, 'list');
})->with(['team', 'project', 'environment', 'server']);
test('an admin adds a shared variable from the canvas modal and the canvas reloads', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(SharedVariablesCanvas::class, ['project_uuid' => $this->project->uuid])
->dispatch('saveKey', ['key' => 'SHOP_URL', 'value' => 'https://shop.test', 'is_multiline' => false, 'is_literal' => false])
->assertRedirect(route('shared-variables.project.canvas', ['project_uuid' => $this->project->uuid]));
$variable = SharedEnvironmentVariable::query()->where('key', 'SHOP_URL')->firstOrFail();
expect($variable->type)->toBe('project')
->and($variable->project_id)->toBe($this->project->id)
->and($variable->team_id)->toBe($this->team->id);
});
test('a member cannot add a shared variable from the canvas, also after switching to an owned team', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$ownTeam = Team::factory()->create();
$member->teams()->attach($ownTeam, ['role' => 'owner']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(SharedVariablesCanvas::class);
$member->load('teams');
session(['currentTeam' => $ownTeam]);
$component
->dispatch('saveKey', ['key' => 'SHOP_URL', 'value' => 'x', 'is_multiline' => false, 'is_literal' => false])
->assertDispatched('error');
expect(SharedEnvironmentVariable::query()->where('key', 'SHOP_URL')->exists())->toBeFalse();
});
test('the server canvas rejects the predefined server variables', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(SharedVariablesCanvas::class, ['server_uuid' => $this->server->uuid])
->dispatch('saveKey', ['key' => 'COOLIFY_SERVER_NAME', 'value' => 'x', 'is_multiline' => false, 'is_literal' => false])
->assertDispatched('error');
expect($this->server->environment_variables()->where('key', 'COOLIFY_SERVER_NAME')->where('value', 'x')->exists())->toBeFalse();
});