feat(canvas): add interactive resource views and team avatars (#12160)

This commit is contained in:
Andras Bacsai
2026-10-14 09:28:10 +02:00
committed by GitHub
parent 448dc8ea0a
commit 33ee09fa87
146 changed files with 12076 additions and 367 deletions
+172
View File
@@ -0,0 +1,172 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create(['show_boarding' => false]);
$this->otherTeam = Team::factory()->create(['show_boarding' => false]);
$this->project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'canvas-team-project']);
$this->otherProject = Project::factory()->create(['team_id' => $this->otherTeam->id, 'name' => 'canvas-other-project']);
$this->user = User::factory()->create();
});
function actingInCanvasTeam(object $test, Team $team): object
{
return $test->actingAs($test->user)->withSession(['currentTeam' => $team])
->withHeader('X-Canvas-Team', (string) $team->id);
}
test('guests are sent to the login page', function () {
$this->get('/canvas')->assertRedirect('/login');
});
test('team members can open the deploy canvas', function () {
$this->team->members()->attach($this->user->id, ['role' => 'member']);
actingInCanvasTeam($this, $this->team)
->withoutVite()
->get('/canvas')
->assertOk()
->assertSee('data-deploy-canvas', false);
});
test('the deploy canvas page lists the service template logos for the service step', function () {
$this->team->members()->attach($this->user->id, ['role' => 'member']);
$template = get_service_templates()->first(fn ($template) => filled(data_get($template, 'logo')) && basename(data_get($template, 'logo')) !== 'default.webp');
$key = get_service_templates()->search($template);
$html = actingInCanvasTeam($this, $this->team)->withoutVite()->get('/canvas')->assertOk()->getContent();
preg_match('/<script type="application\/json" data-deploy-canvas-service-logos>(.*?)<\/script>/s', $html, $matches);
$logos = json_decode($matches[1] ?? '', true);
expect($logos[$key][0])->toBe(service_logo_urls(data_get($template, 'logo'))['logo']);
});
test('guests cannot call the canvas api', function () {
$this->getJson('/canvas/api/projects')->assertUnauthorized();
});
test('the canvas api works with the session and only shows the session team', function () {
$this->team->members()->attach($this->user->id, ['role' => 'admin']);
actingInCanvasTeam($this, $this->team)
->getJson('/canvas/api/projects')
->assertOk()
->assertJsonFragment(['name' => 'canvas-team-project'])
->assertJsonMissing(['name' => 'canvas-other-project']);
});
test('the canvas api uses the team of the open page, not the session team', function () {
// Member of the page's team, owner of the team the session switched to in another tab.
$this->team->members()->attach($this->user->id, ['role' => 'member']);
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
$page = fn () => $this->actingAs($this->user)->withSession(['currentTeam' => $this->otherTeam])
->withHeader('X-Canvas-Team', (string) $this->team->id);
$page()->getJson('/canvas/api/projects')
->assertOk()
->assertJsonFragment(['name' => 'canvas-team-project'])
->assertJsonMissing(['name' => 'canvas-other-project']);
// The role in the page's team applies: no owner rights from the session team.
$page()->postJson('/canvas/api/applications/dockerimage', ['docker_registry_image_name' => 'nginx'])
->assertForbidden();
});
test('the canvas api refuses a missing team or a team the user is not a member of', function () {
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user)->withSession(['currentTeam' => $this->team])
->getJson('/canvas/api/projects')
->assertStatus(400);
$this->actingAs($this->user)->withSession(['currentTeam' => $this->team])
->withHeader('X-Canvas-Team', (string) $this->otherTeam->id)
->getJson('/canvas/api/projects')
->assertUnauthorized();
});
test('the deploy canvas page keeps the team it was opened with', function () {
$this->team->members()->attach($this->user->id, ['role' => 'member']);
actingInCanvasTeam($this, $this->team)
->withoutVite()
->get('/canvas')
->assertOk()
->assertSee('data-team="'.$this->team->id.'"', false);
});
test('admins can create resources through the canvas api', function () {
$this->team->members()->attach($this->user->id, ['role' => 'admin']);
// Passes the permission checks and reaches the controller's validation.
actingInCanvasTeam($this, $this->team)
->postJson('/canvas/api/applications/dockerimage', ['docker_registry_image_name' => 'nginx'])
->assertUnprocessable()
->assertJsonValidationErrors(['project_uuid']);
});
test('members only get read access through the canvas api', function () {
// Owner of another team, but only a member of the session team.
$this->team->members()->attach($this->user->id, ['role' => 'member']);
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
actingInCanvasTeam($this, $this->team)
->getJson('/canvas/api/projects')
->assertOk()
->assertJsonFragment(['name' => 'canvas-team-project'])
->assertJsonMissing(['name' => 'canvas-other-project']);
actingInCanvasTeam($this, $this->team)
->postJson('/canvas/api/applications/dockerimage', ['docker_registry_image_name' => 'nginx'])
->assertForbidden();
actingInCanvasTeam($this, $this->team)
->postJson('/canvas/api/deploy', ['uuid' => 'anything'])
->assertForbidden();
});
test('admins can add projects and environments through the canvas api', function () {
$this->team->members()->attach($this->user->id, ['role' => 'admin']);
$projectUuid = actingInCanvasTeam($this, $this->team)
->postJson('/canvas/api/projects', ['name' => 'canvas-new-project'])
->assertCreated()
->json('uuid');
$project = Project::query()->where('uuid', $projectUuid)->firstOrFail();
expect($project->team_id)->toBe($this->team->id)
->and($project->environments()->pluck('name')->all())->toBe(['production']);
$environmentUuid = actingInCanvasTeam($this, $this->team)
->postJson("/canvas/api/projects/{$projectUuid}/environments", ['name' => 'staging'])
->assertCreated()
->json('uuid');
expect($project->environments()->where('uuid', $environmentUuid)->value('name'))->toBe('staging');
});
test('members cannot add projects or environments, and nobody can add to another team project', function () {
$this->team->members()->attach($this->user->id, ['role' => 'member']);
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
actingInCanvasTeam($this, $this->team)
->postJson('/canvas/api/projects', ['name' => 'canvas-member-project'])
->assertForbidden();
actingInCanvasTeam($this, $this->team)
->postJson("/canvas/api/projects/{$this->project->uuid}/environments", ['name' => 'staging'])
->assertForbidden();
// As owner of the other team, the session team's projects are out of reach.
actingInCanvasTeam($this, $this->otherTeam)
->postJson("/canvas/api/projects/{$this->project->uuid}/environments", ['name' => 'staging'])
->assertNotFound();
expect(Project::query()->where('name', 'canvas-member-project')->exists())->toBeFalse()
->and($this->project->environments()->where('name', 'staging')->exists())->toBeFalse();
});
+439
View File
@@ -0,0 +1,439 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\ResourceLink;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
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' => 'main-server', 'ip' => '203.0.113.10']);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'canvas-env']);
});
function environmentCanvasActingAs(User $user, Team $team): mixed
{
return test()->actingAs($user)->withSession(['currentTeam' => $team])
->withHeader('X-Canvas-Team', (string) $team->id);
}
function environmentCanvasUrl(Project $project, Environment $environment, string $suffix = ''): string
{
return "/canvas/api/projects/{$project->uuid}/environments/{$environment->uuid}/canvas{$suffix}";
}
function environmentCanvasApplication(Environment $environment, StandaloneDocker $destination, array $attributes = []): Application
{
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
...$attributes,
]);
}
function environmentCanvasPostgres(Environment $environment, StandaloneDocker $destination): StandalonePostgresql
{
return create_standalone_postgresql($environment->id, $destination, [
'name' => 'main-db',
'postgres_password' => 'canvas-secret-password',
], 'postgres:17-alpine');
}
describe('GET canvas', function () {
test('returns zones, nodes, links and positions of the environment', function () {
$extraServer = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'extra-server']);
$extraDestination = StandaloneDocker::query()->where('server_id', $extraServer->id)->firstOrFail();
$application = environmentCanvasApplication($this->environment, $this->destination, [
'name' => 'web',
'git_branch' => 'main',
'fqdn' => 'https://web.example.com, https://www.example.com,',
'status' => 'running:healthy',
]);
$application->additional_servers()->attach($extraServer->id, ['standalone_docker_id' => $extraDestination->id, 'status' => 'running:healthy']);
LocalPersistentVolume::create(['name' => 'web-data', 'mount_path' => '/data', 'resource_type' => $application->getMorphClass(), 'resource_id' => $application->id]);
$service = Service::factory()->create([
'name' => 'stack',
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
ServiceApplication::forceCreate(['name' => 'ghost', 'service_id' => $service->id, 'fqdn' => 'https://ghost.example.com', 'status' => 'running:healthy']);
ServiceDatabase::forceCreate(['name' => 'mysql', 'service_id' => $service->id, 'status' => 'running:healthy']);
$database = environmentCanvasPostgres($this->environment, $this->destination);
$link = ResourceLink::create([
'environment_id' => $this->environment->id,
'source_type' => $application->getMorphClass(),
'source_id' => $application->id,
'target_type' => $database->getMorphClass(),
'target_id' => $database->id,
'label' => 'DATABASE_URL',
]);
$this->environment->forceFill(['canvas_positions' => [$application->uuid => ['x' => 10, 'y' => -20], 'gone-resource' => ['x' => 1, 'y' => 1]]])->save();
environmentCanvasActingAs($this->admin, $this->team);
$response = $this
->getJson(environmentCanvasUrl($this->project, $this->environment))
->assertOk();
$response->assertJsonPath('project', ['uuid' => $this->project->uuid, 'name' => $this->project->name, 'link' => route('project.show', ['project_uuid' => $this->project->uuid])])
->assertJsonPath('environment', ['uuid' => $this->environment->uuid, 'name' => 'canvas-env', 'link' => route('project.resource.index', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid])])
->assertJsonPath('can_edit', true)
->assertJsonPath('links', [['id' => $link->uuid, 'source' => $application->uuid, 'target' => $database->uuid, 'label' => 'DATABASE_URL']])
->assertJsonPath('positions', [$application->uuid => ['x' => 10, 'y' => -20]]);
expect(collect($response->json('zones'))->keyBy('id')->all())->toEqual([
$this->server->uuid => ['id' => $this->server->uuid, 'name' => 'main-server', 'ip' => '203.0.113.10', 'is_reachable' => false, 'is_usable' => false, 'link' => route('server.show', ['server_uuid' => $this->server->uuid])],
$extraServer->uuid => ['id' => $extraServer->uuid, 'name' => 'extra-server', 'ip' => $extraServer->ip, 'is_reachable' => false, 'is_usable' => false, 'link' => route('server.show', ['server_uuid' => $extraServer->uuid])],
]);
$nodes = collect($response->json('nodes'))->keyBy('id');
expect($nodes)->toHaveCount(3)
->and($nodes[$application->uuid])->toEqual([
'id' => $application->uuid,
'type' => 'application',
'name' => 'web',
'status' => 'running:healthy',
'zone' => $this->server->uuid,
'extra_zones' => [$extraServer->uuid],
'urls' => ['https://web.example.com', 'https://www.example.com'],
'kind' => 'nixpacks',
'subtitle' => 'Nixpacks · main',
'children' => [],
'volumes' => ['web-data'],
'link' => $application->link(),
'logo' => ['src' => resource_logo_url('public-repo'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => 'https://web.example.com/favicon.ico'],
])
->and($nodes[$service->uuid])->toEqual([
'id' => $service->uuid,
'type' => 'service',
'name' => 'stack',
'status' => 'running:healthy',
'zone' => $this->server->uuid,
'extra_zones' => [],
'urls' => ['https://ghost.example.com'],
'kind' => null,
'subtitle' => 'Service · 2 containers',
'children' => [
['name' => 'ghost', 'type' => 'application', 'status' => 'running:healthy'],
['name' => 'mysql', 'type' => 'database', 'status' => 'running:healthy'],
],
'volumes' => [],
'link' => $service->link(),
'logo' => null,
])
->and($nodes[$database->uuid])->toMatchArray([
'type' => 'database',
'name' => 'main-db',
'zone' => $this->server->uuid,
'urls' => [],
'kind' => 'postgresql',
'subtitle' => 'PostgreSQL · postgres:17-alpine',
'volumes' => ["postgres-data-{$database->uuid}"],
'logo' => ['src' => resource_logo_url('postgres'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => null],
])
->and($response->getContent())->not->toContain('canvas-secret-password');
});
test('drops links whose resource was deleted', function () {
$application = environmentCanvasApplication($this->environment, $this->destination);
$database = environmentCanvasPostgres($this->environment, $this->destination);
ResourceLink::create([
'environment_id' => $this->environment->id,
'source_type' => $application->getMorphClass(),
'source_id' => $application->id,
'target_type' => $database->getMorphClass(),
'target_id' => $database->id,
]);
$database->forceDelete();
environmentCanvasActingAs($this->admin, $this->team)
->getJson(environmentCanvasUrl($this->project, $this->environment))
->assertOk()
->assertJsonPath('links', []);
});
test('hides server ips and editing from a member', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
environmentCanvasApplication($this->environment, $this->destination);
environmentCanvasActingAs($member, $this->team)
->getJson(environmentCanvasUrl($this->project, $this->environment))
->assertOk()
->assertJsonPath('zones.0.ip', null)
->assertJsonPath('can_edit', false);
});
test('finds the environment by name', function () {
environmentCanvasActingAs($this->admin, $this->team)
->getJson("/canvas/api/projects/{$this->project->uuid}/environments/canvas-env/canvas")
->assertOk()
->assertJsonPath('environment.uuid', $this->environment->uuid);
});
test('does not run more queries for more applications', function () {
environmentCanvasActingAs($this->admin, $this->team);
$countQueries = function (): int {
DB::flushQueryLog();
DB::enableQueryLog();
$this
->getJson(environmentCanvasUrl($this->project, $this->environment))
->assertOk();
DB::disableQueryLog();
return collect(DB::getQueryLog())->reject(fn (array $query) => str_contains($query['query'], 'audit_events'))->count();
};
environmentCanvasApplication($this->environment, $this->destination, ['fqdn' => 'https://a.example.com']);
$countQueries();
$queriesForOne = $countQueries();
foreach (range(1, 4) as $index) {
environmentCanvasApplication($this->environment, $this->destination, ['fqdn' => "https://a{$index}.example.com"]);
}
$queriesForFive = $countQueries();
expect($queriesForFive)->toBe($queriesForOne);
});
});
describe('PATCH canvas positions', function () {
test('merges positions, ignores unknown resources and prunes removed ones', function () {
$first = environmentCanvasApplication($this->environment, $this->destination);
$second = environmentCanvasApplication($this->environment, $this->destination);
$this->environment->forceFill(['canvas_positions' => [
$first->uuid => ['x' => 1, 'y' => 2],
'removed-resource' => ['x' => 5, 'y' => 5],
]])->save();
$otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]);
$foreign = environmentCanvasApplication($otherEnvironment, $this->destination);
environmentCanvasActingAs($this->admin, $this->team)
->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [
$second->uuid => ['x' => -300, 'y' => 400],
$foreign->uuid => ['x' => 9, 'y' => 9],
'unknown' => ['x' => 'not-a-number'],
]])
->assertOk()
->assertExactJson(['positions' => [
$first->uuid => ['x' => 1, 'y' => 2],
$second->uuid => ['x' => -300, 'y' => 400],
]]);
expect($this->environment->refresh()->canvas_positions)->toBe([
$first->uuid => ['x' => 1, 'y' => 2],
$second->uuid => ['x' => -300, 'y' => 400],
]);
});
test('rejects coordinates out of range', function () {
$application = environmentCanvasApplication($this->environment, $this->destination);
environmentCanvasActingAs($this->admin, $this->team)
->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [
$application->uuid => ['x' => 100001, 'y' => 0],
]])
->assertUnprocessable()
->assertJsonValidationErrors(["positions.{$application->uuid}.x"]);
});
});
describe('canvas links', function () {
test('creates, relabels and deletes a link', function () {
$application = environmentCanvasApplication($this->environment, $this->destination);
$database = environmentCanvasPostgres($this->environment, $this->destination);
environmentCanvasActingAs($this->admin, $this->team);
$response = $this
->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [
'source' => $application->uuid,
'target' => $database->uuid,
'label' => 'uses',
])
->assertCreated()
->assertJson(['source' => $application->uuid, 'target' => $database->uuid, 'label' => 'uses']);
$linkUuid = $response->json('id');
expect(ResourceLink::query()->whereUuid($linkUuid)->firstOrFail()->created_by)->toBe($this->admin->id);
$this
->patchJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$linkUuid}"), ['label' => 'reads'])
->assertOk()
->assertExactJson(['id' => $linkUuid, 'source' => $application->uuid, 'target' => $database->uuid, 'label' => 'reads']);
$this
->deleteJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$linkUuid}"))
->assertOk()
->assertJson(['message' => 'Link deleted.']);
expect(ResourceLink::query()->count())->toBe(0);
});
test('rejects a link to itself', function () {
$application = environmentCanvasApplication($this->environment, $this->destination);
environmentCanvasActingAs($this->admin, $this->team)
->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [
'source' => $application->uuid,
'target' => $application->uuid,
])
->assertUnprocessable()
->assertJsonValidationErrors(['target']);
});
test('rejects a resource of another environment', function () {
$application = environmentCanvasApplication($this->environment, $this->destination);
$otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]);
$foreign = environmentCanvasApplication($otherEnvironment, $this->destination);
environmentCanvasActingAs($this->admin, $this->team)
->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [
'source' => $application->uuid,
'target' => $foreign->uuid,
])
->assertUnprocessable()
->assertJsonValidationErrors(['target']);
expect(ResourceLink::query()->count())->toBe(0);
});
test('rejects a duplicate link but allows the opposite direction', function () {
$application = environmentCanvasApplication($this->environment, $this->destination);
$database = environmentCanvasPostgres($this->environment, $this->destination);
environmentCanvasActingAs($this->admin, $this->team);
$url = environmentCanvasUrl($this->project, $this->environment, '/links');
$this->postJson($url, ['source' => $application->uuid, 'target' => $database->uuid])->assertCreated();
$this->postJson($url, ['source' => $application->uuid, 'target' => $database->uuid])
->assertUnprocessable()
->assertJsonValidationErrors(['target']);
$this->postJson($url, ['source' => $database->uuid, 'target' => $application->uuid])->assertCreated();
});
test('returns 404 for a link of another environment', function () {
$otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]);
$first = environmentCanvasApplication($otherEnvironment, $this->destination);
$second = environmentCanvasApplication($otherEnvironment, $this->destination);
$link = ResourceLink::create([
'environment_id' => $otherEnvironment->id,
'source_type' => $first->getMorphClass(),
'source_id' => $first->id,
'target_type' => $second->getMorphClass(),
'target_id' => $second->id,
]);
environmentCanvasActingAs($this->admin, $this->team);
$this
->patchJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$link->uuid}"), ['label' => 'x'])
->assertNotFound();
$this
->deleteJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$link->uuid}"))
->assertNotFound();
expect($link->fresh())->not->toBeNull();
});
});
describe('canvas authorization', function () {
test('a member can read the canvas but cannot change it', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$application = environmentCanvasApplication($this->environment, $this->destination);
$database = environmentCanvasPostgres($this->environment, $this->destination);
environmentCanvasActingAs($member, $this->team);
$this->getJson(environmentCanvasUrl($this->project, $this->environment))->assertOk();
$this
->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [$application->uuid => ['x' => 1, 'y' => 1]]])
->assertForbidden();
$this
->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), ['source' => $application->uuid, 'target' => $database->uuid])
->assertForbidden();
expect($this->environment->refresh()->canvas_positions)->toBeNull()
->and(ResourceLink::query()->count())->toBe(0);
});
test('returns 404 for a project of another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$application = environmentCanvasApplication($this->environment, $this->destination);
environmentCanvasActingAs($otherUser, $otherTeam);
$this->getJson(environmentCanvasUrl($this->project, $this->environment))->assertNotFound();
$this
->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [$application->uuid => ['x' => 1, 'y' => 1]]])
->assertNotFound();
});
test('is not part of the public API', function () {
$plainTextToken = 'environment-canvas-test-token';
$token = $this->admin->tokens()->create([
'name' => 'environment-canvas-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['root'],
'team_id' => $this->team->id,
]);
$this->withHeaders(['Authorization' => "Bearer {$token->getKey()}|{$plainTextToken}"])
->getJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}/canvas")
->assertNotFound();
});
});
describe('canvas page', function () {
test('a team member can open the canvas page', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member)->withSession(['currentTeam' => $this->team])
->withoutVite()
->get(route('project.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]))
->assertOk()
->assertSee('data-environment-canvas', false)
->assertSee('data-environment="'.$this->environment->uuid.'"', false);
});
test('another team gets a 404', function () {
$otherTeam = Team::factory()->create(['show_boarding' => false]);
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$this->actingAs($otherUser)->withSession(['currentTeam' => $otherTeam])
->withoutVite()
->get(route('project.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]))
->assertNotFound();
});
});
@@ -91,3 +91,20 @@ it('does not leak admin-only pages to a member through the team search cache', f
expect(globalSearchNavigationLinks($team))->toContain(...adminOnlyNavigationLinks());
expect(globalSearchNavigationLinks($team, 'member'))->not->toContain(...adminOnlyNavigationLinks());
});
it('opens the deploy canvas from the new canvas quick action', function () {
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
$component = Livewire::test(GlobalSearch::class)->call('openSearchModal');
expect(collect($component->get('creatableItems'))->firstWhere('type', 'canvas'))
->toMatchArray(['name' => 'Canvas', 'category' => 'Quick Actions', 'route' => 'deploy-canvas']);
$component->call('navigateToResource', 'canvas')
->assertDispatched('closeSearchModal')
->assertRedirect(route('deploy-canvas'));
});
+37 -1
View File
@@ -95,7 +95,7 @@ it('renders stored localhost server details instead of the fetch action', functi
->assertSee('29.4.3')
->assertSee('Compose version')
->assertSee('2.32.4')
->assertSee('Refresh server details')
->assertSee('Refresh details')
->assertDontSee('Fetch server details');
});
@@ -125,6 +125,22 @@ it('fetches localhost server details from the overview action', function () {
->and($server->composeVersion())->toBe('2.32.4');
});
it('does not fetch server details before the server is validated', function () {
$this->server->settings()->update([
'is_reachable' => false,
'is_usable' => false,
]);
Process::fake();
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('refreshServerMetadata')
->assertDispatched('error', 'Validate the server connection before fetching details.');
Process::assertNothingRan();
expect($this->server->fresh()->server_metadata)->toBeNull();
});
it('refetches localhost compose version from the overview refresh action', function () {
$this->server->update([
'server_metadata' => [
@@ -184,3 +200,23 @@ it('collects localhost server details after a successful connection check', func
->and($server->settings->is_reachable)->toBeTrue()
->and($server->settings->is_usable)->toBeTrue();
});
it('shows validation-only menus as disabled until the server is validated', function () {
$this->server->settings()->update([
'is_reachable' => false,
'is_usable' => false,
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Docker Cleanup')
->assertSee('aria-disabled="true"', false)
->assertDontSee(route('server.docker-cleanup', ['server_uuid' => $this->server->uuid]), false);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee(route('server.docker-cleanup', ['server_uuid' => $this->server->uuid]), false);
});
+38
View File
@@ -171,3 +171,41 @@ it('falls back cleanly when the avatars S3 storage no longer exists', function (
->get(route('profile.avatar'))
->assertNotFound();
});
it('serves a team members profile picture to other members of the same team', function () {
Storage::fake('images');
$team = Team::factory()->create();
$viewer = User::factory()->create();
$member = User::factory()->create([
'avatar_path' => 'avatars/2/avatar.jpg',
'avatar_storage_type' => 'local',
]);
$team->members()->attach($viewer->id, ['role' => 'member']);
$team->members()->attach($member->id, ['role' => 'member']);
Storage::disk('images')->put($member->avatar_path, 'avatar-content');
$this->actingAs($viewer);
expect(profile_avatar_url($member))->toBe(route('team.member.avatar', ['user_id' => $member->id, 'v' => $member->updated_at->timestamp]));
$this->withoutMiddleware()
->get(route('team.member.avatar', ['user_id' => $member->id]))
->assertSuccessful()
->assertHeader('content-type', 'image/jpeg');
});
it('does not serve the profile picture of a user who shares no team', function () {
Storage::fake('images');
$viewer = User::factory()->create();
$viewer->teams()->attach(Team::factory()->create()->id, ['role' => 'owner']);
$stranger = User::factory()->create([
'avatar_path' => 'avatars/3/avatar.jpg',
'avatar_storage_type' => 'local',
]);
$stranger->teams()->attach(Team::factory()->create()->id, ['role' => 'owner']);
Storage::disk('images')->put($stranger->avatar_path, 'avatar-content');
$this->withoutMiddleware()->actingAs($viewer)
->get(route('team.member.avatar', ['user_id' => $stranger->id]))
->assertNotFound();
});
+209
View File
@@ -0,0 +1,209 @@
<?php
use App\Livewire\Project\ProjectCanvas;
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 Illuminate\Foundation\Testing\RefreshDatabase;
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->staging = Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'staging']);
$this->production = $this->project->environments()->where('name', 'production')->firstOrFail();
});
function projectCanvasApp(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 projectCanvasSession(object $test, User $user, Team $team): object
{
return $test->actingAs($user)->withSession(['currentTeam' => $team])
->withHeader('X-Canvas-Team', (string) $team->id);
}
test('the project canvas shows the resources of every environment, grouped by environment', function () {
$app = projectCanvasApp($this->staging, $this->server, 'storefront');
$api = projectCanvasApp($this->staging, $this->server, 'orders-api');
$live = projectCanvasApp($this->production, $this->server, 'storefront-live');
$link = ResourceLink::create([
'environment_id' => $this->staging->id,
'source_type' => $app->getMorphClass(), 'source_id' => $app->id,
'target_type' => $api->getMorphClass(), 'target_id' => $api->id,
]);
$this->production->forceFill(['canvas_positions' => [$live->uuid => ['x' => 40, 'y' => 80]]])->save();
// A resource of another project of the same team stays out.
$other = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]);
projectCanvasApp($other, $this->server, 'elsewhere');
$response = projectCanvasSession($this, $this->admin, $this->team)
->getJson("/canvas/api/projects/{$this->project->uuid}/canvas")
->assertOk()
->assertJsonPath('title', 'Shop')
->assertJsonPath('can_edit', true)
->assertJsonPath('layout', 'server')
->assertJsonPath('links.0.id', $link->uuid)
->assertJsonPath('positions', [$live->uuid => ['x' => 40, 'y' => 80]]);
expect(collect($response->json('zones'))->pluck('name')->all())->toBe(['web-server'])
->and(collect($response->json('nodes'))->mapWithKeys(fn ($node) => [$node['name'] => $node['context']])->sortKeys()->all())->toBe([
'orders-api' => 'staging',
'storefront' => 'staging',
'storefront-live' => 'production',
])
// The canvas saves moves and links on the environment canvas of each card.
->and(collect($response->json('nodes'))->firstWhere('name', 'storefront-live')['environment'])->toBe(['uuid' => $this->production->uuid, 'name' => 'production']);
});
test('members see the project canvases but cannot edit them', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
projectCanvasSession($this, $member, $this->team)
->getJson("/canvas/api/projects/{$this->project->uuid}/canvas")
->assertOk()
->assertJsonPath('can_edit', false);
projectCanvasSession($this, $member, $this->team)
->getJson('/canvas/api/projects/canvas')
->assertOk()
->assertJsonPath('can_edit', false);
});
test('the project canvas is out of reach from another team', 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']);
// Session switched to the other team: this team's project is out of reach.
projectCanvasSession($this, $outsider, $otherTeam)
->getJson("/canvas/api/projects/{$this->project->uuid}/canvas")
->assertNotFound();
projectCanvasSession($this, $outsider, $otherTeam)
->get(route('project.canvas', ['project_uuid' => $this->project->uuid]))
->assertNotFound();
});
test('the project page reopens the canvas when the user left it on the canvas view', function () {
$parameters = ['project_uuid' => $this->project->uuid];
projectCanvasSession($this, $this->admin, $this->team)
->withoutVite()
->get(route('project.canvas', $parameters))
->assertOk()
->assertSee('data-endpoint="/projects/'.$this->project->uuid.'/canvas"', false)
->assertCookie("coolify_{$this->admin->id}_project_view", 'canvas');
projectCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_project_view", 'canvas')
->get(route('project.show', $parameters))
->assertRedirect(route('project.canvas', $parameters));
// The table / grid links on the canvas pick the list view again.
projectCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_project_view", 'canvas')
->get(route('project.show', [...$parameters, 'view' => 'grid']))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_project_view", 'grid');
});
test('the projects canvas shows every project of the team as a zone, grouped by environment', function () {
$app = projectCanvasApp($this->staging, $this->server, 'storefront');
$blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Blog']);
$post = projectCanvasApp($blog->environments()->firstOrFail(), $this->server, 'wordpress');
// Another team's project stays out.
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$foreign = Project::factory()->create(['team_id' => $otherTeam->id, 'name' => 'Foreign']);
projectCanvasApp($foreign->environments()->firstOrFail(), $otherServer, 'secret-app');
$response = projectCanvasSession($this, $this->admin, $this->team)
->getJson('/canvas/api/projects/canvas')
->assertOk()
->assertJsonPath('title', 'Projects')
->assertJsonPath('can_edit', true)
->assertJsonPath('layout', 'server');
expect(collect($response->json('zones'))->pluck('name')->all())->toBe(['Blog', 'Shop'])
->and(collect($response->json('nodes'))->mapWithKeys(fn ($node) => [$node['name'] => [$node['zone'], $node['context']]])->sortKeys()->all())->toBe([
'storefront' => [$this->project->uuid, 'staging'],
'wordpress' => [$blog->uuid, 'production'],
]);
});
test('the projects page reopens the canvas when the user left it on the canvas view', function () {
projectCanvasSession($this, $this->admin, $this->team)
->get(route('project.canvas.index'))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_projects_view", 'canvas');
projectCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_projects_view", 'canvas')
->get(route('project.index'))
->assertRedirect(route('project.canvas.index'));
projectCanvasSession($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_projects_view", 'canvas')
->get(route('project.index', ['view' => 'table']))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_projects_view", 'table');
});
test('an admin creates an environment from the project canvas', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ProjectCanvas::class, ['project_uuid' => $this->project->uuid])
->set('name', 'testing')
->call('submit')
->assertRedirect();
expect($this->project->environments()->where('name', 'testing')->exists())->toBeTrue();
});
test('a member cannot create an environment from the project canvas', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ProjectCanvas::class, ['project_uuid' => $this->project->uuid])
->set('name', 'testing')
->call('submit')
->assertDispatched('error');
expect($this->project->environments()->where('name', 'testing')->exists())->toBeFalse();
});
+71
View File
@@ -0,0 +1,71 @@
<?php
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Service;
use App\Models\StandaloneKeydb;
use App\Models\StandalonePostgresql;
beforeEach(function () {
config(['cache.default' => 'array']);
});
test('databases get the logo of their engine', function () {
expect(resource_logo(new StandalonePostgresql))
->toBe(['src' => resource_logo_url('postgres'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => null])
->and(resource_logo(new StandaloneKeydb))
->toBe(['src' => resource_logo_url('keydb'), 'dark' => resource_logo_url('keydb-dark'), 'fallback' => null, 'padded' => false, 'favicon' => null]);
});
test('applications get the logo of how they are built', function (string $buildPack, ?string $sourceType, string $logo) {
$application = (new Application)->forceFill(['build_pack' => $buildPack, 'source_type' => $sourceType]);
expect(resource_logo($application)['src'])->toBe(resource_logo_url($logo));
})->with([
['nixpacks', null, 'public-repo'],
['railpack', GithubApp::class, 'github-app'],
['static', GitlabApp::class, 'gitlab-app'],
['dockerimage', null, 'docker-image'],
['dockerfile', GithubApp::class, 'dockerfile'],
['dockercompose', GitlabApp::class, 'docker-compose'],
]);
test('services get the logo of their template, with the CDN as fallback', function () {
$template = get_service_templates()->get('n8n');
expect(resource_logo(new Service(['name' => 'renamed', 'service_type' => 'n8n'])))->toBe([
'src' => asset($template->logo),
'dark' => null,
'fallback' => service_logo_urls($template->logo)['logo_cdn_url'],
'padded' => true,
'favicon' => null,
]);
});
test('services created before the service type was stored use the template in their name', function () {
expect(resource_logo(new Service(['name' => 'n8n-abc123']))['src'])->toBe(asset(get_service_templates()->get('n8n')->logo));
});
test('services without a template logo have no logo', function () {
expect(resource_logo(new Service(['name' => 'custom-compose'])))->toBeNull();
});
test('applications with a domain get the favicon of their first domain', function (?string $fqdn, ?string $favicon) {
$application = (new Application)->forceFill(['build_pack' => 'nixpacks', 'fqdn' => $fqdn]);
expect(resource_logo($application)['favicon'])->toBe($favicon);
})->with([
['https://app.example.com/dashboard,https://www.example.com', 'https://app.example.com/favicon.ico'],
[' http://app.example.com:8080 ', 'http://app.example.com:8080/favicon.ico'],
[null, null],
['app.example.com', null],
['https://*.example.com', null],
]);
test('resource logo URLs carry the file modification time, so browsers load changed logos', function () {
$modifiedAt = filemtime(public_path('svgs/resources/docker-image.svg'));
expect(resource_logo_url('docker-image'))->toBe(asset('svgs/resources/docker-image.svg')."?v={$modifiedAt}")
->and(resource_logo_url('missing-logo'))->toBe(asset('svgs/resources/missing-logo.svg'));
});
+325
View File
@@ -0,0 +1,325 @@
<?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();
});
@@ -48,7 +48,7 @@ it('uses resource tile icons for databases', function () {
$databases = collect((new Select)->loadServices()['databases'])->keyBy('id');
foreach (['keydb', 'dragonfly', 'clickhouse'] as $database) {
expect($databases[$database]['logo'])->toBe(asset("svgs/resources/{$database}.svg"));
expect($databases[$database]['logo'])->toBe(resource_logo_url($database));
}
});
+2 -1
View File
@@ -21,11 +21,12 @@ function serverSettingsSearchGroups(): array
],
'General' => [
['label' => 'Proxy', 'route' => 'server.proxy'],
['label' => 'Metrics', 'route' => 'server.metrics', 'disabled' => true],
],
];
}
it('lists pages, visible child pages and in-page sections with their breadcrumbs', function () {
it('lists enabled pages, visible child pages and in-page sections with their breadcrumbs', function () {
InstanceSettings::forceCreate(['id' => 0]);
$items = settingsSearchItems(
+342
View File
@@ -0,0 +1,342 @@
<?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('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();
});
+3 -2
View File
@@ -350,14 +350,15 @@ describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
expect($this->application->tags()->count())->toBe(0);
});
test('garbage-collects orphaned tag', function () {
test('keeps the tag after its last resource is removed', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
expect(Tag::find($tag->id))->toBeNull();
// Tags can exist without resources; delete them with DELETE /api/v1/tags/{uuid}.
expect(Tag::find($tag->id))->not->toBeNull();
});
test('returns 404 when deleting a tag that is not attached to the application', function () {
+226
View File
@@ -0,0 +1,226 @@
<?php
use App\Livewire\Project\Shared\Tags as ResourceTags;
use App\Livewire\Tags\Canvas as TagsCanvas;
use App\Livewire\Tags\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Tag;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$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 = Application::factory()->create([
'name' => 'storefront', 'build_pack' => 'dockerimage', 'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(),
]);
$this->tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
});
function tagCanvasAs(object $test, User $user, Team $team): object
{
return $test->actingAs($user)->withSession(['currentTeam' => $team])
->withHeader('X-Canvas-Team', (string) $team->id);
}
test('the tags canvas shows every tag, also without resources, and which resources have them', function () {
$database = StandalonePostgresql::create([
'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db',
'image' => 'postgres:17', 'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(),
]);
$this->storefront->tags()->attach($this->tag->id);
$database->tags()->attach($this->tag->id);
$empty = Tag::create(['name' => 'unused', 'team_id' => $this->team->id]);
$response = tagCanvasAs($this, $this->admin, $this->team)
->getJson('/canvas/api/tags/canvas')
->assertOk()
->assertJsonPath('layout', 'connect')
->assertJsonPath('can_edit', true);
expect(collect($response->json('nodes'))->mapWithKeys(fn ($node) => [$node['id'] => $node['type']])->all())->toBe([
'tag:'.$this->tag->uuid => 'tag',
'tag:'.$empty->uuid => 'tag',
$database->uuid => 'database',
$this->storefront->uuid => 'application',
])->and(collect($response->json('links'))->pluck('id')->sort()->values()->all())->toBe(collect([
'tag:'.$this->tag->uuid.'|'.$database->uuid,
'tag:'.$this->tag->uuid.'|'.$this->storefront->uuid,
])->sort()->values()->all());
});
test('an admin tags and untags a resource from the canvas, and the tag stays', function () {
$source = 'tag:'.$this->tag->uuid;
tagCanvasAs($this, $this->admin, $this->team)
->postJson('/canvas/api/tags/canvas/links', ['source' => $source, 'target' => $this->storefront->uuid])
->assertCreated()
->assertJsonPath('id', $source.'|'.$this->storefront->uuid);
expect($this->storefront->tags()->pluck('name')->all())->toBe(['production']);
tagCanvasAs($this, $this->admin, $this->team)
->postJson('/canvas/api/tags/canvas/links', ['source' => $source, 'target' => $this->storefront->uuid])
->assertStatus(422)
->assertJsonPath('message', 'storefront already has the production tag.');
tagCanvasAs($this, $this->admin, $this->team)
->deleteJson('/canvas/api/tags/canvas/links/'.rawurlencode($source.'|'.$this->storefront->uuid))
->assertOk();
expect($this->storefront->tags()->count())->toBe(0)
->and(Tag::find($this->tag->id))->not->toBeNull();
});
test('a member sees the tags canvas but cannot tag resources', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
tagCanvasAs($this, $member, $this->team)->getJson('/canvas/api/tags/canvas')
->assertOk()
->assertJsonPath('can_edit', false);
tagCanvasAs($this, $member, $this->team)
->postJson('/canvas/api/tags/canvas/links', ['source' => 'tag:'.$this->tag->uuid, 'target' => $this->storefront->uuid])
->assertForbidden();
expect($this->storefront->tags()->count())->toBe(0);
});
test('tags and resources of another team cannot be connected', function () {
$otherTeam = Team::factory()->create(['show_boarding' => false]);
$otherTeam->members()->attach($this->admin->id, ['role' => 'owner']);
$otherTag = Tag::create(['name' => 'foreign', 'team_id' => $otherTeam->id]);
// The session is switched to the other team after the canvas was opened.
tagCanvasAs($this, $this->admin, $otherTeam)
->postJson('/canvas/api/tags/canvas/links', ['source' => 'tag:'.$otherTag->uuid, 'target' => $this->storefront->uuid])
->assertStatus(422)
->assertJsonValidationErrors('target');
tagCanvasAs($this, $this->admin, $this->team)
->postJson('/canvas/api/tags/canvas/links', ['source' => 'tag:'.$otherTag->uuid, 'target' => $this->storefront->uuid])
->assertStatus(422)
->assertJsonValidationErrors('source');
expect($this->storefront->tags()->count())->toBe(0);
});
test('the tags page creates a tag without resources and deletes a tag', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Show::class)
->set('newTagName', 'Staging')
->call('createTag')
->assertHasNoErrors()
->assertRedirect(route('tags.show', ['tagName' => 'staging']));
expect(Tag::query()->where('team_id', $this->team->id)->where('name', 'staging')->exists())->toBeTrue();
Livewire::test(Show::class)->set('newTagName', 'production')->call('createTag')->assertHasErrors('newTagName');
Livewire::test(Show::class)->set('newTagName', 'two words')->call('createTag')->assertHasErrors('newTagName');
$this->storefront->tags()->attach($this->tag->id);
Livewire::test(Show::class, ['tagName' => 'production'])
->call('deleteTag')
->assertRedirect(route('tags.show'));
expect(Tag::find($this->tag->id))->toBeNull()
->and($this->storefront->tags()->count())->toBe(0);
});
test('the tags canvas creates a tag, and a member cannot', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(TagsCanvas::class)
->assertSee('New tag')
->set('newTagName', 'Staging')
->call('createTag')
->assertHasNoErrors()
->assertRedirect(route('tags.show', ['tagName' => 'staging']));
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
Livewire::test(TagsCanvas::class)
->assertDontSee('New tag')
->set('newTagName', 'qa')
->call('createTag');
expect(Tag::query()->where('team_id', $this->team->id)->orderBy('name')->pluck('name')->all())->toBe(['production', 'staging']);
});
test('a member cannot create or delete tags on the tags page', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(Show::class)->set('newTagName', 'staging')->call('createTag');
Livewire::test(Show::class, ['tagName' => 'production'])->call('deleteTag');
expect(Tag::query()->where('team_id', $this->team->id)->pluck('name')->all())->toBe(['production']);
});
test('removing the last resource from a tag on the resource page keeps the tag', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->storefront->tags()->attach($this->tag->id);
Livewire::test(ResourceTags::class, ['resource' => $this->storefront])
->call('deleteTag', (string) $this->tag->id);
expect($this->storefront->tags()->count())->toBe(0)
->and(Tag::find($this->tag->id))->not->toBeNull();
});
test('the tags canvas page opens a tag from its connect link', function () {
tagCanvasAs($this, $this->admin, $this->team)->get(route('tags.canvas'))
->assertOk()
->assertSee('data-mode="tags"', false);
tagCanvasAs($this, $this->admin, $this->team)->get(route('tags.show', ['tagName' => 'production']))
->assertOk()
->assertSee(route('tags.canvas').'?select=tag%3A'.$this->tag->uuid, false);
});
test('the tags page reopens the canvas when the user left it on the canvas view', function () {
tagCanvasAs($this, $this->admin, $this->team)
->get(route('tags.canvas'))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_tags_view", 'canvas');
tagCanvasAs($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_tags_view", 'canvas')
->get(route('tags.show'))
->assertRedirect(route('tags.canvas'));
tagCanvasAs($this, $this->admin, $this->team)
->withCookie("coolify_{$this->admin->id}_tags_view", 'canvas')
->get(route('tags.show', ['view' => 'grid']))
->assertOk()
->assertCookie("coolify_{$this->admin->id}_tags_view", 'grid');
});
+1 -1
View File
@@ -33,7 +33,7 @@ it('shows an empty state when there are no tags', function () {
Livewire::test(Show::class)
->assertSee('Tags')
->assertSee('No tags yet')
->assertSee('Open a resource and add a tag to start grouping related deployments.');
->assertSee('Create a tag, then connect it to resources to group related deployments.');
});
it('shows a card grid overview when tags exist and none is selected', function () {
+168
View File
@@ -0,0 +1,168 @@
<?php
use App\Livewire\SwitchTeam;
use App\Livewire\Team\Index;
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'avatar_storage_type' => 'local',
]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
function giveTeamStoredAvatar(Team $team): string
{
$path = "team-avatars/{$team->id}/avatar.jpg";
$team->forceFill(['avatar_path' => $path, 'avatar_storage_type' => 'local'])->save();
Storage::disk('images')->put($path, 'avatar-content');
return $path;
}
it('stores a team avatar using the instance image storage setting', function () {
Storage::fake('images');
$upload = UploadedFile::fake()->createWithContent('team.jpg', file_get_contents(base_path('tests/Fixtures/project-icon.jpg')));
Livewire::test(Index::class)
->set('avatar', $upload)
->call('uploadAvatar')
->assertHasNoErrors()
->assertDispatched('team-avatar-updated');
$this->team->refresh();
expect($this->team->avatar_path)->toBe("team-avatars/{$this->team->id}/avatar.jpg")
->and($this->team->avatar_storage_type)->toBe('local')
->and($this->team->avatar_s3_storage_id)->toBeNull();
Storage::disk('images')->assertExists($this->team->avatar_path);
});
it('does not let a team member change the team avatar', function () {
Storage::fake('images');
$path = giveTeamStoredAvatar($this->team);
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(Index::class)
->set('avatar', UploadedFile::fake()->createWithContent('team.jpg', file_get_contents(base_path('tests/Fixtures/project-icon.jpg'))))
->call('uploadAvatar')
->call('removeAvatar');
expect($this->team->refresh()->avatar_path)->toBe($path);
Storage::disk('images')->assertExists($path);
expect(Storage::disk('images')->get($path))->toBe('avatar-content');
});
it('removes a team avatar', function () {
Storage::fake('images');
$path = giveTeamStoredAvatar($this->team);
Livewire::test(Index::class)
->call('removeAvatar')
->assertHasNoErrors();
expect($this->team->refresh()->avatar_path)->toBeNull();
Storage::disk('images')->assertMissing($path);
});
it('serves a team avatar only to members of the team', function () {
Storage::fake('images');
giveTeamStoredAvatar($this->team);
$this->get(route('team.avatar', ['team_id' => $this->team->id]))
->assertSuccessful()
->assertHeader('content-type', 'image/jpeg');
$otherUser = User::factory()->create();
$otherTeam = Team::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
$this->get(route('team.avatar', ['team_id' => $this->team->id]))
->assertNotFound();
});
it('serves the avatar of another team the user belongs to', function () {
Storage::fake('images');
$otherTeam = Team::factory()->create();
$this->user->teams()->attach($otherTeam, ['role' => 'member']);
giveTeamStoredAvatar($otherTeam);
$this->get(route('team.avatar', ['team_id' => $otherTeam->id]))
->assertSuccessful();
});
it('deletes the avatar file when the team is deleted', function () {
Storage::fake('images');
$path = giveTeamStoredAvatar($this->team);
$this->team->delete();
Storage::disk('images')->assertMissing($path);
});
it('shows team avatars in the team switcher', function () {
$otherTeam = Team::factory()->create(['name' => 'Zeta']);
$this->user->teams()->attach($otherTeam, ['role' => 'member']);
$otherTeam->forceFill(['avatar_path' => "team-avatars/{$otherTeam->id}/avatar.jpg", 'avatar_storage_type' => 'local'])->save();
$this->user->load('teams');
Livewire::test(SwitchTeam::class)
->assertSeeHtml('src="'.team_avatar_url($otherTeam).'"');
});
it('stores a team avatar on the instance S3 storage and serves it from the CDN', function () {
Team::factory()->create(['id' => 0]);
$storage = S3Storage::query()->create([
'team_id' => 0,
'name' => 'Avatar storage',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'avatars',
'endpoint' => 'https://1.1.1.1',
'is_usable' => true,
]);
InstanceSettings::findOrFail(0)->update([
'avatar_storage_type' => 's3',
'avatar_s3_storage_id' => $storage->id,
'image_cdn_url' => 'https://avatars.example.com/media',
]);
$s3Disk = Storage::fake('team-avatar-s3');
Storage::extend('s3', fn () => $s3Disk);
Storage::fake('images');
$upload = UploadedFile::fake()->createWithContent('team.jpg', file_get_contents(base_path('tests/Fixtures/project-icon.jpg')));
Livewire::test(Index::class)
->set('avatar', $upload)
->call('uploadAvatar')
->assertHasNoErrors()
->assertNotDispatched('error');
$this->team->refresh();
expect($this->team->avatar_storage_type)->toBe('s3')
->and($this->team->avatar_s3_storage_id)->toBe($storage->id)
->and(team_avatar_url($this->team))->toBe("https://avatars.example.com/media/team-avatars/{$this->team->id}/avatar.jpg?v={$this->team->updated_at->timestamp}");
$s3Disk->assertExists($this->team->avatar_path);
Storage::disk('images')->assertMissing($this->team->avatar_path);
});
+161
View File
@@ -0,0 +1,161 @@
<?php
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
$this->stack = seedBrowserResourceStack();
});
it('opens the deploy canvas for the environment from its New resource page', function () {
$environmentPath = "/project/{$this->stack['project']->uuid}/environment/{$this->stack['environment']->uuid}";
loginAndSkipBoarding();
$page = visit("{$environmentPath}/new");
$page->assertSee('Choose a resource')
->click('a:visible:has-text("Deploy canvas")')
->wait(1)
->assertPathIs('/canvas')
->assertSee('Deploy canvas')
->assertSee('Browser Project')
->assertSee('production')
->assertSee('localhost')
->assertDontSee('API token')
->wait(0.5);
expect($page->script("document.querySelector('.cv-back').getAttribute('href')"))->toBe($environmentPath);
// The Server step is there, connected to Start, with the team's only server selected.
$flow = $page->script("JSON.parse(localStorage.getItem('coolify-canvas:flow'))");
expect($flow['edges'])->toContain(['from' => 'start', 'to' => 'server'])
->and(collect($flow['nodes'])->firstWhere('id', 'server')['data']['server'] ?? null)->toBe($this->stack['server']->uuid);
$page->screenshot(filename: 'deploy-canvas');
});
it('adds a project and an environment from the Start step', function () {
loginAndSkipBoarding();
$page = visit('/canvas');
$page->assertSee('Deploy canvas')
->click('.cv-node[data-id="start"] .cv-field:nth-child(1) .cv-select')
->click('.cv-dd-new')
->fill('.cv-dd-form input', 'Shop')
->keys('.cv-dd-form input', 'Enter')
->wait(1.5);
$project = Project::query()->where('name', 'Shop')->firstOrFail();
$start = fn () => collect($page->script("JSON.parse(localStorage.getItem('coolify-canvas:flow'))")['nodes'])->firstWhere('id', 'start')['data'];
expect($start())->toBe(['project' => $project->uuid, 'environment' => $project->environments()->value('uuid')]);
$page->click('.cv-node[data-id="start"] .cv-field:nth-child(2) .cv-select')
->click('.cv-dd-new')
->fill('.cv-dd-form input', 'staging')
->keys('.cv-dd-form input', 'Enter')
->wait(1.5)
->assertSee('staging')
->screenshot(filename: 'deploy-canvas-add-environment');
expect($start()['environment'])->toBe($project->environments()->where('name', 'staging')->value('uuid'));
});
it('picks a one-click service template from a searchable list', function () {
loginAndSkipBoarding();
$page = visit('/canvas');
$page->assertSee('Deploy canvas')
->click('.cv-palette .cv-item:has-text("Service")')
->click('.cv-node:has(b:text-is("Service")) .cv-select')
->fill('.cv-dd-search', 'uptime kuma')
->assertSee('Uptime Kuma With Mariadb')
->keys('.cv-dd-search', 'Enter')
->assertSee('Uptime Kuma')
->wait(0.5)
->screenshot(filename: 'deploy-canvas-service-template');
$flow = $page->script("JSON.parse(localStorage.getItem('coolify-canvas:flow'))");
expect(collect($flow['nodes'])->firstWhere('type', 'service')['data']['type'] ?? null)->toBe('uptime-kuma');
});
it('shows a closable not-optimized-for-mobile notice only on small screens', function () {
$project = $this->stack['project']->uuid;
$environment = $this->stack['environment']->uuid;
loginAndSkipBoarding();
$page = visit("/canvas?project={$project}&environment={$environment}");
$page->assertSee('Deploy canvas')
->assertDontSee('The canvas is not optimized for mobile yet.')
->resize(390, 844)
->assertSee('The canvas is not optimized for mobile yet.')
->screenshot(filename: 'deploy-canvas-mobile-notice');
expect($page->script("document.querySelector('.cv-notice a').getAttribute('href')"))
->toBe("/project/{$project}/environment/{$environment}/new");
$page->click('[aria-label="Close this notice"]')
->assertDontSee('The canvas is not optimized for mobile yet.')
->refresh()
->assertSee('Deploy canvas')
->assertDontSee('The canvas is not optimized for mobile yet.')
->screenshot(filename: 'deploy-canvas-mobile-notice-closed');
});
it('zooms around the fingers with a two-finger pinch', function () {
loginAndSkipBoarding();
$page = visit('/canvas');
$page->assertSee('Deploy canvas')->wait(0.5);
// Two touch pointers 100px apart move to 200px apart: the zoom doubles from 100% to the 200% maximum.
$zoom = $page->script(<<<'JS'
(() => {
const viewport = document.querySelector('.cv-viewport');
const r = viewport.getBoundingClientRect();
const y = r.top + r.height / 2;
const cx = r.left + r.width / 2;
const fire = (type, id, x) => viewport.dispatchEvent(new PointerEvent(type, { pointerId: id, pointerType: 'touch', isPrimary: id === 1, clientX: x, clientY: y, button: 0, bubbles: true, cancelable: true }));
document.querySelector('button[title="Reset zoom to 100%"]').click();
fire('pointerdown', 1, cx - 50);
fire('pointerdown', 2, cx + 50);
fire('pointermove', 1, cx - 100);
fire('pointermove', 2, cx + 100);
fire('pointerup', 1, cx - 100);
fire('pointerup', 2, cx + 100);
return document.querySelector('button[title="Reset zoom to 100%"]').textContent;
})()
JS);
expect($zoom)->toBe('200%');
$page->screenshot(filename: 'deploy-canvas-pinch');
});
it('starts with an empty canvas after a successful deploy', function (bool $deployed, int $expectedNodes) {
loginAndSkipBoarding();
$page = visit('/canvas');
$page->assertSee('Deploy canvas');
$flow = json_encode(['nodes' => [
['id' => 'start', 'type' => 'start', 'x' => 80, 'y' => 160, 'data' => []],
['id' => 'server', 'type' => 'server', 'x' => 456, 'y' => 160, 'data' => []],
['id' => 'image-1', 'type' => 'image', 'x' => 832, 'y' => 160, 'data' => ['image' => 'nginx']],
['id' => 'deploy', 'type' => 'deploy', 'x' => 1240, 'y' => 160, 'data' => []],
], 'edges' => []]);
$page->script("localStorage.setItem('coolify-canvas:flow', JSON.stringify({$flow})); localStorage.setItem('coolify-canvas:deployed', '".json_encode($deployed)."');");
$page->navigate('/canvas')
->assertSee('Deploy canvas');
expect($page->script("document.querySelectorAll('.cv-node').length"))->toBe($expectedNodes);
$page->screenshot(filename: 'deploy-canvas-after-deploy');
})->with([
'after a deploy' => [true, 3],
'without a deploy' => [false, 4],
]);