Files
coolify/tests/Feature/CrossTeamDestinationAttachTest.php
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

402 lines
18 KiB
PHP

<?php
use App\Actions\Application\StopApplicationOneServer;
use App\Actions\Docker\GetContainersStatus;
use App\Livewire\Project\Shared\Destination;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
// Attacker: Team A
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'name' => 'dest-a-'.fake()->unique()->word(),
'network' => 'coolify-a-'.fake()->unique()->word(),
]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
// A second usable destination on Team A's own server, used for positive-path tests.
$this->serverA2 = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA2 = StandaloneDocker::factory()->create([
'server_id' => $this->serverA2->id,
'name' => 'dest-a2-'.fake()->unique()->word(),
'network' => 'coolify-a2-'.fake()->unique()->word(),
]);
// Victim: Team B
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'name' => 'dest-b-'.fake()->unique()->word(),
'network' => 'coolify-b-'.fake()->unique()->word(),
]);
foreach ([$this->serverA, $this->serverA2, $this->serverB] as $server) {
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
}
// Act as attacker (Team A)
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
afterEach(function () {
GetContainersStatus::clearFake();
});
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
test('cannot attach another team\'s server + network to own application', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
// handleError on ModelNotFoundException calls abort(404); pivot assertion is source of truth.
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
expect($this->applicationA->fresh()->additional_servers)->toHaveCount(0);
});
test('cannot attach own network paired with another team\'s server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('cannot attach another team\'s network paired with own server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationB->id, $this->serverA2->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('cannot attach own network paired with wrong own server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('can attach own team\'s server + network to own application', function () {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA2->id);
$additional = $this->applicationA->fresh()->additional_networks;
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA2->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA2->id);
});
test('attaching the same server twice does not create duplicate destinations', function () {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA2->id)
->call('addServer', $this->destinationA2->id, $this->serverA2->id);
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->count())->toBe(1);
});
test('the database rejects duplicate application server destinations', function () {
$destination = [
'application_id' => $this->applicationA->id,
'server_id' => $this->serverA2->id,
'standalone_docker_id' => $this->destinationA2->id,
];
DB::table('additional_destinations')->insert($destination);
expect(fn () => DB::table('additional_destinations')->insert($destination))
->toThrow(QueryException::class);
});
});
describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {
test('cannot promote another team\'s network as the application\'s main destination', function () {
$originalDestinationId = $this->applicationA->destination_id;
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
});
test('cannot promote own network paired with wrong own server', function () {
$originalDestinationId = $this->applicationA->destination_id;
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
});
test('can promote own team network and preserve previous main as additional network', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
$application = $this->applicationA->fresh();
$additional = $application->additional_networks;
expect($application->destination_id)->toBe($this->destinationA2->id);
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
});
test('refresh failures after promote do not roll back promoted destination', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
GetContainersStatus::shouldRun()
->once()
->andThrow(new RuntimeException('refresh failed'));
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
} catch (Throwable $e) {
// The refresh failure is intentionally outside the transaction; persistence is the assertion.
}
$application = $this->applicationA->fresh();
$additional = $application->additional_networks;
expect($application->destination_id)->toBe($this->destinationA2->id);
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
});
test('only detaches the promoted network for the selected pivot server', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA->id)
->exists())->toBeTrue();
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->exists())->toBeFalse();
});
});
describe('Destination::removeServer', function () {
test('only detaches the removed network for the selected pivot server', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('removeServer', $this->destinationA2->id, $this->serverA2->id, 'password');
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA->id)
->exists())->toBeTrue();
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->exists())->toBeFalse();
});
});
describe('Destination on a server that is not usable', function () {
test('redeploy shows an error and queues no deployment when the server is not reachable', function (array $settings) {
$this->serverA2->settings->update($settings);
Server::flushIdentityMap();
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('redeploy', $this->destinationA2->id, $this->serverA2->id)
->assertDispatched('error', 'Failed to deploy.', "The server {$this->serverA2->name} is not reachable. Check the connection on the server page and try again.")
->assertStatus(200);
expect(ApplicationDeploymentQueue::query()->where('server_id', $this->serverA2->id)->exists())->toBeFalse();
})->with([
'unreachable' => [['is_reachable' => false]],
'unusable' => [['is_usable' => false]],
]);
test('addServer shows an error and attaches nothing when the server is not reachable', function () {
$this->serverA2->settings->update(['is_reachable' => false]);
Server::flushIdentityMap();
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA2->id)
->assertDispatched('error', 'Failed to add server.', "The server {$this->serverA2->name} is not reachable. Check the connection on the server page and try again.")
->assertStatus(200);
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('promote shows an error and keeps the main destination when the server is not reachable', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->serverA2->settings->update(['is_reachable' => false]);
Server::flushIdentityMap();
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id)
->assertDispatched('error', 'Failed to promote.', "The server {$this->serverA2->name} is not reachable. Check the connection on the server page and try again.")
->assertStatus(200);
expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA->id);
});
test('redeploy to a server of another team is still rejected without a toast', function () {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('redeploy', $this->destinationB->id, $this->serverB->id)
->assertNotDispatched('error')
->assertStatus(404);
expect(ApplicationDeploymentQueue::query()->where('server_id', $this->serverB->id)->exists())->toBeFalse();
});
});
describe('Destination::stop', function () {
test('stops the application on an additional server that is no longer usable', function (array $settings) {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->serverA2->settings->update($settings);
Server::flushIdentityMap();
StopApplicationOneServer::shouldRun()->once()
->withArgs(fn (Application $application, Server $server) => $application->is($this->applicationA) && $server->is($this->serverA2));
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('stop', $this->serverA2->id)
->assertHasNoErrors();
})->with([
'unreachable' => [['is_reachable' => false]],
'unusable' => [['is_usable' => false]],
'force disabled' => [['force_disabled' => true]],
]);
test('cannot stop the application on a server of another team', function () {
StopApplicationOneServer::shouldNotRun();
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('stop', $this->serverB->id);
});
});
describe('Destination after the user switches the session team', function () {
beforeEach(function () {
Server::flushIdentityMap();
$this->userA->teams()->attach($this->teamB, ['role' => 'owner']);
foreach ([$this->serverA2, $this->serverB] as $server) {
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
}
$this->component = Livewire::test(Destination::class, ['resource' => $this->applicationA]);
session(['currentTeam' => $this->teamB]);
});
test('lists only networks of the application team', function () {
$networkIds = $this->component->call('loadData')->get('networks')->pluck('id');
expect($networkIds)->toContain($this->destinationA2->id)
->not->toContain($this->destinationB->id);
});
test('cannot attach a server of the session team to the application', function () {
try {
$this->component->call('addServer', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('can attach a server of the application team', function () {
$this->component->call('addServer', $this->destinationA2->id, $this->serverA2->id);
expect($this->applicationA->fresh()->additional_networks->pluck('id')->all())->toBe([$this->destinationA2->id]);
});
test('cannot promote a network of the session team', function () {
try {
$this->component->call('promote', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA->id);
});
test('can promote a network of the application team', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->component->call('promote', $this->destinationA2->id, $this->serverA2->id);
expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA2->id);
});
test('cannot redeploy to a server of the session team', function () {
try {
$this->component->call('redeploy', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect(ApplicationDeploymentQueue::query()->where('server_id', $this->serverB->id)->exists())->toBeFalse();
});
});