Files
coolify/tests/Feature/ServerTransferPreviewOwnershipTest.php
Andras BacsaiandClaude Opus 5.5 a82d32c946 chore: merge main into release/v4.5
Resolve conflicts between main's security fixes and v4.5 features:
- Keep v4.5 features (log redaction, multi-S3 backups, transfer flow,
  scheduled task modal, sidebar) and port main's fixes into them.
- Reuse RequiresDeployAbility for the Hostinger create endpoint.
- Align MCP tool abilities and descriptions with main's new deploy
  requirements, and use deploy tokens in the affected MCP tests.
- Drop the duplicate DockerCleanupJobTest helper and tests, and the
  exportBundle test for a method v4.5 removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:00:05 +02:00

159 lines
7.3 KiB
PHP

<?php
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferImporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
// An import validates the claimed server over SSH; the sync test queue would run it.
Queue::fake([ValidateAndInstallServerJob::class]);
config()->set('app.env', 'local');
config()->set('app.maintenance.driver', 'file');
Server::flushIdentityMap();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
Process::fake();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->mock(ServerTransferClaimer::class)->shouldReceive('claim')->andReturn([]);
});
/** @return array{application: Application, preview: ApplicationPreview, bundle: array} */
function previewOwnershipFixture(Team $team, bool $trashed = false): array
{
$key = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $key->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$application = Application::factory()->create([
'environment_id' => $project->environments()->firstOrFail()->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 42,
'pull_request_html_url' => 'https://github.com/example/app/pull/42',
'fqdn' => 'https://preview.example.com',
]);
$bundle = app(ServerTransferExporter::class)->export($server);
$bundle['server']['uuid'] = new_public_id();
$bundle['server']['ip'] = '192.0.2.99';
if ($trashed) {
$application->delete();
$preview->delete();
}
return compact('application', 'preview', 'bundle');
}
test('preview imports reject another team UUID and roll back all imported records', function (bool $trashed, string $role) {
$fixture = previewOwnershipFixture(Team::factory()->create(), $trashed);
$preview = $fixture['preview'];
$volume = LocalPersistentVolume::create([
'resource_type' => $preview->getMorphClass(),
'resource_id' => $preview->id,
'name' => 'victim-preview-data',
'mount_path' => '/data',
]);
$bundle = $fixture['bundle'];
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['pull_request_id'] = 99;
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['fqdn'] = 'https://imported.example.com';
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => $role]);
session(['currentTeam' => $team]);
$token = $user->createToken('preview-import', ['write', 'deploy']);
$token->accessToken->forceFill(['team_id' => $team->id])->save();
$counts = [Server::count(), Application::withTrashed()->count(), PrivateKey::count(), Project::count()];
$this->withToken($token->plainTextToken)->postJson('/api/v1/servers/import', ['bundle' => $bundle, 'adopt_mode' => false])
->assertUnprocessable()
->assertJsonValidationErrors('previews');
expect($preview->fresh()->application_id)->toBe($fixture['application']->id)
->and($preview->fresh()->pull_request_id)->toBe(42)
->and($preview->fresh()->fqdn)->toBe('https://preview.example.com')
->and($preview->fresh()->trashed())->toBe($trashed);
expect($volume->fresh()->resource_id)->toBe($preview->id);
expect([Server::count(), Application::withTrashed()->count(), PrivateKey::count(), Project::count()])->toBe($counts);
})->with([
'active preview, owner' => [false, 'owner'],
'deleted preview and application, admin' => [true, 'admin'],
]);
test('preview imports reject another team domain without clearing it', function (bool $trashed, bool $preserveUuids) {
$fixture = previewOwnershipFixture(Team::factory()->create(), $trashed);
$bundle = $fixture['bundle'];
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['uuid'] = new_public_id();
$team = Team::factory()->create();
$serverCount = Server::count();
try {
app(ServerTransferImporter::class)->import($bundle, $team->id, preserveUuids: $preserveUuids, adoptMode: false);
$this->fail('A foreign preview domain must be rejected.');
} catch (ValidationException $exception) {
expect($exception->errors())->toHaveKey('previews');
}
expect($fixture['preview']->fresh()->fqdn)->toBe('https://preview.example.com')
->and($fixture['preview']->fresh()->application_id)->toBe($fixture['application']->id)
->and($fixture['preview']->fresh()->trashed())->toBe($trashed);
expect(Server::count())->toBe($serverCount);
$this->assertDatabaseCount('application_previews', 1);
})->with([
'active, preserve UUIDs' => [false, true],
'deleted, replace UUIDs' => [true, false],
]);
test('preview imports preserve same-team UUID handoffs and preview volumes', function (bool $trashed) {
$team = Team::factory()->create();
$fixture = previewOwnershipFixture($team, $trashed);
$preview = $fixture['preview'];
$volume = LocalPersistentVolume::create([
'resource_type' => $preview->getMorphClass(),
'resource_id' => $preview->id,
'name' => 'same-team-preview-data',
'mount_path' => '/data',
]);
$result = app(ServerTransferImporter::class)->import($fixture['bundle'], $team->id, adoptMode: false);
$imported = Server::whereUuid($result['server_uuid'])->firstOrFail()->applications()->firstOrFail();
expect($preview->fresh()->application_id)->toBe($imported->id)
->and($preview->fresh()->fqdn)->toBe('https://preview.example.com')
->and($preview->fresh()->trashed())->toBeFalse();
expect($volume->fresh()->resource_id)->toBe($preview->id);
$this->assertDatabaseCount('application_previews', 1);
})->with(['active' => false, 'deleted preview and application' => true]);
test('preview imports can release a same-team domain collision', function () {
$team = Team::factory()->create();
$fixture = previewOwnershipFixture($team);
$bundle = $fixture['bundle'];
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['uuid'] = new_public_id();
$result = app(ServerTransferImporter::class)->import($bundle, $team->id, adoptMode: false);
$imported = Server::whereUuid($result['server_uuid'])->firstOrFail()->applications()->firstOrFail();
expect($fixture['preview']->fresh()->fqdn)->toBeNull();
expect($imported->previews()->firstOrFail()->fqdn)->toBe('https://preview.example.com');
});