Files
coolify/tests/Feature/Livewire/FileBrowserTest.php
Aditya Tripathi e050ea339c refactor(files): Monaco editor, single-stat listing, kebab actions, hardening
Address review feedback and UX issues on the file browser.

Editor
- Replace the TipTap editor with Monaco, reusing the self-hosted assets. The
  editor is kept mounted (wire:ignore) and preloaded on page init, and receives
  content + language via the load-file-editor event, applying the language with
  setModelLanguage so highlighting is correct without recreating the instance.
- Keep the Monaco instance on the DOM node, not in Alpine reactive state, so its
  object graph is never proxied (that hung and crashed the tab).
- Drop @tiptap/*, lowlight and highlight.js; remove the TipTap CSS and JS.
- Broaden guessLanguage to Monaco ids and common extension-less/dotfiles.

Listing / read (perf)
- List a directory in one stat call instead of a per-entry loop (embedding a
  real tab, since stat -c does not expand \t), and surface owner/group columns.
- Fold the read size-cap, binary probe and base64 into one SSH round trip.

Actions
- Collapse the per-row Edit/Download/Rename/Delete buttons into a 3-dots menu.
- Fix actions silently failing on names with apostrophes: the entry name now
  lives in the row's Alpine scope instead of passing @js() through a component
  attribute (which double-encodes the quotes).

Hardening
- Lock client-controllable state (container/type/resource) with #[Locked].
- Re-check guard() in open/goTo/refresh; sanitize upload filenames; clean up the
  download temp file and report errors; route >96KiB writes through docker cp.

Tests updated and extended (32 passing). Frontend rebuild (npm run build) is
still needed only to drop TipTap from the bundle; the editor works without it.
2026-08-27 16:31:39 +00:00

243 lines
9.6 KiB
PHP

<?php
use App\Livewire\Project\Shared\FileBrowser;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
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\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
Storage::fake('ssh-keys');
InstanceSettings::forceCreate(['id' => 0]);
config(['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']);
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
]);
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
});
it('blocks non-admin members from the file browser', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $application])
->assertForbidden();
});
it('shows a stopped state when the container is not running', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'exited',
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $application])
->assertSet('containerRunning', false)
->assertSee('Start the container to browse its files');
});
it('lists the default directory on mount for a running database', function () {
Process::fake(['*' => Process::sequence()
->push(Process::result(output: '/var/lib/postgresql')) // defaultRoot inspect
->push(Process::result(output: "dir\t0\t1\tdata\nfile\t10\t2\tpostgresql.conf"))]); // list
$database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test DB',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'image' => 'postgres:15',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $database])
->assertSet('containerRunning', true)
->assertSet('currentPath', '/var/lib/postgresql')
->assertSet('container', $database->uuid)
->assertSee('data')
->assertSee('postgresql.conf');
});
function fbRunningDatabase($environment, $destination): StandalonePostgresql
{
return StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'DB',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'image' => 'postgres:15',
'status' => 'running',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
}
it('creates a directory and re-lists', function () {
Process::fake(['*' => Process::sequence()
->push(Process::result(output: '/data')) // defaultRoot
->push(Process::result(output: '')) // initial list (empty)
->push(Process::result(output: '')) // mkdir
->push(Process::result(output: "dir\t0\t1\tplugins"))]); // re-list
$database = fbRunningDatabase($this->environment, $this->destination);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $database])
->call('createDirectory', 'plugins')
->assertSee('plugins');
});
it('creates a file and re-lists', function () {
Process::fake(['*' => Process::sequence()
->push(Process::result(output: '/data')) // defaultRoot
->push(Process::result(output: '')) // initial list (empty)
->push(Process::result(output: '')) // createFile
->push(Process::result(output: "file\t0\t1\t644\tconfig.yml"))]); // re-list
$database = fbRunningDatabase($this->environment, $this->destination);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $database])
->call('createFile', 'config.yml')
->assertSee('config.yml');
});
it('changes permissions and re-lists', function () {
Process::fake(['*' => Process::sequence()
->push(Process::result(output: '/data')) // defaultRoot
->push(Process::result(output: "file\t10\t1\t644\tapp.sh")) // initial list
->push(Process::result(output: '')) // chmod
->push(Process::result(output: "file\t10\t1\t755\tapp.sh"))]); // re-list
$database = fbRunningDatabase($this->environment, $this->destination);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $database])
->assertSee('644')
->call('chmodEntry', 'app.sh', '755')
->assertSee('755');
});
it('sets the editor language and content from the file when opening', function () {
Process::fake(['*' => Process::sequence()
->push(Process::result(output: '/data')) // defaultRoot
->push(Process::result(output: "file\t12\t1\t644\tconfig.yml")) // initial list
->push(Process::result(output: "OK\n".base64_encode("a: 1\n")))]); // single-round-trip read
$database = fbRunningDatabase($this->environment, $this->destination);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $database])
->call('openEditor', 'config.yml')
->assertSet('editorOpen', true)
->assertSet('editorLanguage', 'yaml')
->assertSet('editorContent', "a: 1\n")
->assertDispatched('load-file-editor', content: "a: 1\n", language: 'yaml');
});
it('resolves a Monaco language for common extension-less files', function () {
Process::fake(['*' => Process::sequence()
->push(Process::result(output: '/data')) // defaultRoot
->push(Process::result(output: "file\t12\t1\t644\tDockerfile")) // initial list
->push(Process::result(output: "OK\n".base64_encode("FROM alpine\n")))]); // read
$database = fbRunningDatabase($this->environment, $this->destination);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $database])
->call('openEditor', 'Dockerfile')
->assertSet('editorLanguage', 'dockerfile');
});
it('refuses to open a binary or oversized file in the editor', function () {
Process::fake(['*' => Process::sequence()
->push(Process::result(output: '/data')) // defaultRoot
->push(Process::result(output: "file\t99999999\t1\tbig.bin")) // initial list
->push(Process::result(output: 'TOOBIG'))]); // single-round-trip read
$database = fbRunningDatabase($this->environment, $this->destination);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $database])
->call('openEditor', 'big.bin')
->assertSet('editingPath', null);
});
it('rejects a client attempt to change the locked container property', function () {
Process::fake(['*' => Process::sequence()
->push(Process::result(output: '/data')) // defaultRoot
->push(Process::result(output: ''))]); // initial list
$database = fbRunningDatabase($this->environment, $this->destination);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(FileBrowser::class, ['resource' => $database])
->set('container', 'someone-elses-container');
})->throws(CannotUpdateLockedPropertyException::class);