mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 00:33:44 -04:00
feat(canvas): add interactive resource views and team avatars (#12160)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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],
|
||||
]);
|
||||
Reference in New Issue
Block a user