mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 10:04:36 +08:00
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
468 lines
22 KiB
PHP
468 lines
22 KiB
PHP
<?php
|
|
|
|
use App\Jobs\ServerStorageSaveJob;
|
|
use App\Livewire\Project\Service\FileStorage;
|
|
use App\Livewire\Project\Service\Storage;
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
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\Bus;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Bus::fake();
|
|
Server::flushIdentityMap();
|
|
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
|
config([
|
|
'app.maintenance.store' => 'array',
|
|
'cache.default' => 'array',
|
|
'constants.ssh.mux_enabled' => false,
|
|
]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->admin = User::factory()->create();
|
|
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
|
$this->member = User::factory()->create();
|
|
$this->member->teams()->attach($this->team, ['role' => 'member']);
|
|
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
$this->application = Application::factory()->create([
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
|
|
storageHostPathFakeServer();
|
|
$this->actingAs($this->admin);
|
|
session(['currentTeam' => $this->team]);
|
|
});
|
|
|
|
/**
|
|
* `test -L` prints $symlink and `test -f`/`test -d` print $exists. The file state check sees a file when $exists is OK.
|
|
*/
|
|
function storageHostPathFakeServer(string $symlink = 'OK', string $exists = 'NOK'): void
|
|
{
|
|
Process::fake(fn ($process) => Process::result(output: match (true) {
|
|
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput($exists === 'OK' ? 'file' : 'missing'),
|
|
str_contains(remoteProcessScript($process), 'test -L') => $symlink,
|
|
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
|
|
str_contains(remoteProcessScript($process), 'test -') => $exists,
|
|
default => '',
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $abilities
|
|
*/
|
|
function storageHostPathToken(User $user, array $abilities = ['*']): string
|
|
{
|
|
// A token request must not use the session user of the Livewire tests.
|
|
auth()->forgetGuards();
|
|
$plainTextToken = Str::random(40);
|
|
$token = $user->tokens()->create([
|
|
'name' => 'host-path-token',
|
|
'token' => hash('sha256', $plainTextToken),
|
|
'abilities' => $abilities,
|
|
'team_id' => test()->team->id,
|
|
]);
|
|
|
|
return $token->getKey().'|'.$plainTextToken;
|
|
}
|
|
|
|
/**
|
|
* @return array{0: string, 1: array<string, string>, 2: mixed}
|
|
*/
|
|
function storageHostPathApiTarget(string $resourceType): array
|
|
{
|
|
if ($resourceType === 'application') {
|
|
return ['/api/v1/applications/'.test()->application->uuid.'/storages', [], test()->application];
|
|
}
|
|
if ($resourceType === 'database') {
|
|
$database = StandalonePostgresql::create([
|
|
'name' => 'host-path-postgres',
|
|
'image' => 'postgres:15-alpine',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'postgres',
|
|
'environment_id' => test()->environment->id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
]);
|
|
|
|
return ["/api/v1/databases/{$database->uuid}/storages", [], $database];
|
|
}
|
|
|
|
$service = Service::factory()->create([
|
|
'environment_id' => test()->environment->id,
|
|
'server_id' => test()->server->id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n",
|
|
]);
|
|
$serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]);
|
|
|
|
return ["/api/v1/services/{$service->uuid}/storages", ['resource_uuid' => $serviceApplication->uuid], $serviceApplication];
|
|
}
|
|
|
|
function storageHostPathFileVolume(string $fsPath, ?string $content, bool $isDirectory = false): LocalFileVolume
|
|
{
|
|
return LocalFileVolume::create([
|
|
'fs_path' => $fsPath,
|
|
'mount_path' => '/mnt/'.md5($fsPath),
|
|
'content' => $content,
|
|
'is_directory' => $isDirectory,
|
|
'resource_id' => test()->application->id,
|
|
'resource_type' => test()->application->getMorphClass(),
|
|
])->fresh();
|
|
}
|
|
|
|
function storageHostPathAssertNoDelete(): void
|
|
{
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -') || str_contains(remoteProcessScript($process), 'rmdir'));
|
|
}
|
|
|
|
test('an admin creates a directory mount anywhere on the host in the UI', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_directory_source', '/srv/zz')
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory')
|
|
->assertDispatched('success');
|
|
|
|
$volume = LocalFileVolume::query()->sole();
|
|
expect($volume->fs_path)->toBe('/srv/zz')
|
|
->and($volume->is_directory)->toBeTrue();
|
|
|
|
$volume->saveStorageOnServer();
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '/srv/zz'"));
|
|
});
|
|
|
|
test('an admin creates a file mount with content anywhere on the host in the UI', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_source', '/etc/zz/app.conf')
|
|
->set('file_storage_path', '/etc/app.conf')
|
|
->set('file_storage_content', 'listen 8080;')
|
|
->call('submitFileStorage')
|
|
->assertDispatched('success');
|
|
|
|
$volume = LocalFileVolume::query()->sole();
|
|
expect($volume->fs_path)->toBe('/etc/zz/app.conf')
|
|
->and($volume->mount_path)->toBe('/etc/app.conf');
|
|
|
|
$volume->saveStorageOnServer();
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '/etc/zz'"));
|
|
Process::assertRan(fn ($process) => preg_match("#base64 -d \\| (sudo )?tee '/etc/zz/app.conf' > /dev/null#", remoteProcessScript($process)) === 1);
|
|
expect($volume->contentPathOnServer())->toBe('/etc/zz/app.conf');
|
|
});
|
|
|
|
test('an admin creates directory and file mounts anywhere on the host through the API', function (string $resourceType) {
|
|
[$url, $payload, $resource] = storageHostPathApiTarget($resourceType);
|
|
$token = storageHostPathToken($this->admin);
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson($url, [...$payload, ...[
|
|
'type' => 'file',
|
|
'is_directory' => true,
|
|
'fs_path' => '/srv/zz',
|
|
'mount_path' => '/data',
|
|
]])->assertCreated()->assertJsonPath('fs_path', '/srv/zz');
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson($url, [...$payload, ...[
|
|
'type' => 'file',
|
|
'fs_path' => '/etc/zz/app.conf',
|
|
'mount_path' => '/etc/app.conf',
|
|
'content' => 'listen 8080;',
|
|
]])->assertCreated()->assertJsonPath('fs_path', '/etc/zz/app.conf');
|
|
|
|
expect($resource->fileStorages()->orderBy('id')->pluck('fs_path')->all())->toBe(['/srv/zz', '/etc/zz/app.conf']);
|
|
})->with(['application', 'database', 'service']);
|
|
|
|
test('members cannot create host path mounts', function () {
|
|
$this->actingAs($this->member);
|
|
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_directory_source', '/srv/zz')
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory')
|
|
->assertDispatched('error');
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->member)])
|
|
->postJson("/api/v1/applications/{$this->application->uuid}/storages", [
|
|
'type' => 'file',
|
|
'fs_path' => '/etc/zz/app.conf',
|
|
'mount_path' => '/etc/app.conf',
|
|
'content' => 'listen 8080;',
|
|
])->assertForbidden();
|
|
|
|
expect(LocalFileVolume::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('host paths with traversal, home or shell characters are rejected', function (string $path) {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_directory_source', $path)
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory')
|
|
->assertDispatched('error');
|
|
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_source', $path)
|
|
->set('file_storage_path', '/etc/app.conf')
|
|
->set('file_storage_content', 'listen 8080;')
|
|
->call('submitFileStorage')
|
|
->assertDispatched('error');
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)])
|
|
->postJson("/api/v1/applications/{$this->application->uuid}/storages", [
|
|
'type' => 'file',
|
|
'is_directory' => true,
|
|
'fs_path' => $path,
|
|
'mount_path' => '/data',
|
|
])->assertUnprocessable();
|
|
|
|
expect(LocalFileVolume::query()->count())->toBe(0);
|
|
})->with([
|
|
'parent segment' => '/srv/../etc/zz',
|
|
'relative escape' => '../../../etc/zz',
|
|
'home directory' => '~/zz',
|
|
'command separator' => '/srv/zz;id',
|
|
'command substitution' => '/srv/$(id)',
|
|
]);
|
|
|
|
test('a symbolic link at a file path outside the resource directory is refused', function () {
|
|
storageHostPathFakeServer(symlink: 'LINK');
|
|
$volume = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;');
|
|
|
|
expect(fn () => $volume->saveStorageOnServer())
|
|
->toThrow(RuntimeException::class, '/etc/zz/app.conf is a symbolic link on the server.');
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d'));
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'touch '));
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)])
|
|
->postJson("/api/v1/applications/{$this->application->uuid}/storages", [
|
|
'type' => 'file',
|
|
'fs_path' => '/etc/zz/other.conf',
|
|
'mount_path' => '/etc/other.conf',
|
|
'content' => 'listen 8080;',
|
|
])->assertUnprocessable();
|
|
|
|
expect(LocalFileVolume::query()->count())->toBe(1);
|
|
});
|
|
|
|
test('deleting a mount outside the resource directory never deletes on the host', function () {
|
|
storageHostPathFakeServer(exists: 'OK');
|
|
$directory = storageHostPathFileVolume('/srv/zz', null, isDirectory: true);
|
|
$file = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;');
|
|
|
|
$directory->deleteStorageOnServer();
|
|
$file->deleteStorageOnServer();
|
|
storageHostPathAssertNoDelete();
|
|
|
|
Livewire::test(FileStorage::class, ['fileStorage' => $file->fresh()])
|
|
->set('permanently_delete', true)
|
|
->call('delete', 'password')
|
|
->assertDispatched('success', 'File deleted.');
|
|
|
|
storageHostPathAssertNoDelete();
|
|
expect(LocalFileVolume::query()->pluck('fs_path')->all())->toBe(['/srv/zz']);
|
|
});
|
|
|
|
test('deleting a mount inside the resource directory still deletes it on the host', function () {
|
|
storageHostPathFakeServer(exists: 'OK');
|
|
$path = $this->application->workdir().'/data/zz';
|
|
|
|
storageHostPathFileVolume($path, null, isDirectory: true)->deleteStorageOnServer();
|
|
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "rm -rf '{$path}'"));
|
|
});
|
|
|
|
test('the UI deletes a mount inside the resource directory on the host only when the user keeps the option checked', function (bool $isDirectory, array $selectedActions, bool $deletesOnHost, string $message) {
|
|
storageHostPathFakeServer(exists: 'OK');
|
|
$path = $this->application->workdir().'/data/zz';
|
|
$volume = storageHostPathFileVolume($path, $isDirectory ? null : 'listen 8080;', isDirectory: $isDirectory);
|
|
|
|
Livewire::test(FileStorage::class, ['fileStorage' => $volume])
|
|
->call('delete', 'password', $selectedActions)
|
|
->assertDispatched('success', $message);
|
|
|
|
expect($volume->fresh())->toBeNull();
|
|
if ($deletesOnHost) {
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "rm -rf '{$path}'"));
|
|
} else {
|
|
storageHostPathAssertNoDelete();
|
|
}
|
|
})->with([
|
|
'directory, option checked' => [true, ['permanently_delete'], true, 'Directory deleted from the server.'],
|
|
'directory, option unchecked' => [true, [], false, 'Directory deleted.'],
|
|
'file, option checked' => [false, ['permanently_delete'], true, 'File deleted from the server.'],
|
|
'file, option unchecked' => [false, [], false, 'File deleted.'],
|
|
]);
|
|
|
|
test('the delete dialog offers deletion on the host only for mounts inside the resource directory', function () {
|
|
$inside = storageHostPathFileVolume($this->application->workdir().'/data/zz', 'listen 8080;');
|
|
$outside = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;');
|
|
$outsideDirectory = storageHostPathFileVolume('/srv/zz', null, isDirectory: true);
|
|
|
|
$insideDialog = Livewire::test(FileStorage::class, ['fileStorage' => $inside]);
|
|
expect($insideDialog->viewData('deletionCheckboxes'))->toHaveCount(1)
|
|
->and($insideDialog->viewData('deletionCheckboxes')[0]['id'])->toBe('permanently_delete');
|
|
|
|
foreach ([$outside, $outsideDirectory] as $volume) {
|
|
$dialog = Livewire::test(FileStorage::class, ['fileStorage' => $volume]);
|
|
expect($dialog->viewData('deletionCheckboxes'))->toBe([])
|
|
->and(implode(' ', $dialog->viewData('deletionActions')))->toContain('is not deleted on the server');
|
|
}
|
|
});
|
|
|
|
test('a file outside the resource directory never replaces a directory on the host', function () {
|
|
Process::fake(fn ($process) => Process::result(output: match (true) {
|
|
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('directory'),
|
|
str_contains(remoteProcessScript($process), 'test -L') => 'OK',
|
|
str_contains(remoteProcessScript($process), 'test -d') => 'OK',
|
|
str_contains(remoteProcessScript($process), 'test -f') => 'NOK',
|
|
str_contains(remoteProcessScript($process), 'sh -c') => '1:empty-directory',
|
|
default => '',
|
|
}));
|
|
|
|
expect(fn () => storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;')->saveStorageOnServer())
|
|
->toThrow(Exception::class, 'is a directory on the server');
|
|
storageHostPathAssertNoDelete();
|
|
});
|
|
|
|
test('relative host paths stay inside the resource directory', function () {
|
|
$workdir = $this->application->workdir();
|
|
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_directory_source', 'data/zz')
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory')
|
|
->assertDispatched('success');
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)])
|
|
->postJson("/api/v1/applications/{$this->application->uuid}/storages", [
|
|
'type' => 'file',
|
|
'fs_path' => './config/app.conf',
|
|
'mount_path' => '/etc/app.conf',
|
|
'content' => 'listen 8080;',
|
|
])->assertCreated();
|
|
|
|
expect(LocalFileVolume::query()->orderBy('id')->pluck('fs_path')->all())
|
|
->toBe([$workdir.'/data/zz', $workdir.'/config/app.conf']);
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'readlink -f')
|
|
&& str_contains(remoteProcessScript($process), "'{$workdir}' '{$workdir}/data/zz'"));
|
|
});
|
|
|
|
test('a token without deploy cannot create a mount outside the resource directory', function (string $resourceType, array $mount) {
|
|
[$url, $payload] = storageHostPathApiTarget($resourceType);
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])
|
|
->postJson($url, [...$payload, 'type' => 'file', ...$mount])
|
|
->assertForbidden()
|
|
->assertJsonPath('message', 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.');
|
|
|
|
expect(LocalFileVolume::query()->count())->toBe(0);
|
|
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
|
Process::assertNothingRan();
|
|
})->with(['application', 'database', 'service'])->with([
|
|
'directory' => [['is_directory' => true, 'fs_path' => '/root/.ssh', 'mount_path' => '/data']],
|
|
'file with content' => [['fs_path' => '/root/.ssh/authorized_keys', 'mount_path' => '/etc/keys', 'content' => 'ssh-ed25519 AAAA']],
|
|
'empty file' => [['fs_path' => '/etc/zz/app.conf', 'mount_path' => '/etc/app.conf']],
|
|
]);
|
|
|
|
test('a deploy or root token creates a mount outside the resource directory', function (string $resourceType, array $abilities) {
|
|
[$url, $payload, $resource] = storageHostPathApiTarget($resourceType);
|
|
$token = storageHostPathToken($this->admin, $abilities);
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
|
->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => '/srv/zz', 'mount_path' => '/data'])
|
|
->assertCreated();
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
|
->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => '/etc/zz/app.conf', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;'])
|
|
->assertCreated();
|
|
|
|
expect($resource->fileStorages()->orderBy('id')->pluck('fs_path')->all())->toBe(['/srv/zz', '/etc/zz/app.conf']);
|
|
})->with(['application', 'database', 'service'])->with([
|
|
'deploy' => [['read', 'write', 'deploy']],
|
|
'root' => [['root']],
|
|
]);
|
|
|
|
test('a write token creates mounts inside the resource directory', function (string $resourceType) {
|
|
[$url, $payload, $resource] = storageHostPathApiTarget($resourceType);
|
|
$token = storageHostPathToken($this->admin, ['read', 'write']);
|
|
$workdir = $resource instanceof ServiceApplication ? $resource->service->workdir() : $resource->workdir();
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
|
->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => 'data/zz', 'mount_path' => '/data'])
|
|
->assertCreated();
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
|
->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => $workdir.'/config/app.conf', 'mount_path' => '/etc/app.conf'])
|
|
->assertCreated();
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
|
->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/other.conf'])
|
|
->assertCreated();
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
|
->postJson($url, [...$payload, 'type' => 'file', 'is_host_file' => true, 'fs_path' => '/etc/hosts', 'mount_path' => '/etc/hosts'])
|
|
->assertCreated();
|
|
|
|
expect($resource->fileStorages()->count())->toBe(4);
|
|
})->with(['application', 'database', 'service']);
|
|
|
|
test('changing the content of a mount outside the resource directory needs deploy', function (string $resourceType) {
|
|
[$url, , $resource] = storageHostPathApiTarget($resourceType);
|
|
$storage = LocalFileVolume::create([
|
|
'fs_path' => '/etc/zz/app.conf',
|
|
'mount_path' => '/etc/app.conf',
|
|
'content' => 'listen 8080;',
|
|
'resource_id' => $resource->id,
|
|
'resource_type' => $resource->getMorphClass(),
|
|
]);
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])
|
|
->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'content' => 'ssh-ed25519 AAAA'])
|
|
->assertForbidden()
|
|
->assertJsonPath('message', 'Missing required permissions: deploy. Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.');
|
|
expect($storage->fresh()->content)->toBe('listen 8080;');
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])
|
|
->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'mount_path' => '/etc/app2.conf', 'content' => 'listen 8080;'])
|
|
->assertOk();
|
|
expect($storage->fresh()->mount_path)->toBe('/etc/app2.conf');
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write', 'deploy'])])
|
|
->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'content' => 'listen 9090;'])
|
|
->assertOk();
|
|
expect($storage->fresh()->content)->toBe('listen 9090;');
|
|
})->with(['application', 'database', 'service']);
|
|
|
|
test('a failed conversion keeps the storage type that is on the server', function (string $action, bool $isDirectory) {
|
|
// The legacy parser stored this raw source, so the server step rejects it.
|
|
$volume = storageHostPathFileVolume('${AO_CONFIG_FILE:-./agent-orchestrator.yaml}', null, $isDirectory);
|
|
|
|
Livewire::test(FileStorage::class, ['fileStorage' => $volume])
|
|
->call($action)
|
|
->assertDispatched('error');
|
|
|
|
expect($volume->fresh()->is_directory)->toBe($isDirectory);
|
|
})->with([
|
|
'to file' => ['convertToFile', true],
|
|
'to directory' => ['convertToDirectory', false],
|
|
]);
|