From 90131f088a87b97edc9f29e5a872778a49b60923 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 01:51:27 +0200 Subject: [PATCH] Add a raw .env editor to the Integrations page, with a net under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything the console has no field for is still real — MAIL_*, DB_*, APP_KEY, whichever key nobody has built a form for yet — and without a way to touch it from here, the operator needs a shell and the console page misses its own point. EnvFileEditor is the net, not just the warning: 1. Validates before writing. A line that is neither blank, nor a comment, nor KEY=value is rejected outright, and an empty file (syntactically "valid" by that rule, but not survivable) is refused too. write() never touches the file before checking the new content. 2. Backs up before every write that actually happens — a timestamped copy beside .env, before the new content lands. Never pruned automatically; the page says so, next to where it says where they land. 3. Names the keys a mistake here can lock an operator out with — APP_KEY, DB_*, REDIS_*, SESSION_* — rather than a blanket warning nobody reads. 4. Gated by secrets.manage and the same confirmed password the vault entries use — this is the one place on the page that can reach every credential the vault otherwise keeps write-only. 5. Says plainly what saving does not do: queue, queue-provisioning, scheduler and reverb only read .env at their own startup, and names the restart command plus config:clear. 6. Marks which .env keys SecretVault currently overrides, so editing a line that a stored vault value already shadows does not look broken. Two issues surfaced by Codex review and fixed before this commit: the raw file content stayed in the Livewire component snapshot after the password confirmation window expired on its own (not only on an explicit re-lock), and the first save on an installation with no .env yet failed trying to back up a file that was never there. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 + app/Livewire/Admin/ConfirmSaveEnv.php | 35 +++ app/Livewire/Admin/Integrations.php | 95 ++++++++- app/Services/Env/EnvFileEditor.php | 146 +++++++++++++ .../Env/InvalidEnvContentException.php | 35 +++ app/Services/Secrets/SecretVault.php | 14 ++ lang/de/integrations.php | 33 +++ lang/en/integrations.php | 33 +++ .../livewire/admin/confirm-save-env.blade.php | 17 ++ .../livewire/admin/integrations.blade.php | 76 ++++++- tests/Feature/Admin/EnvFileEditorTest.php | 137 ++++++++++++ tests/Feature/Admin/IntegrationsPageTest.php | 201 +++++++++++++++++- 12 files changed, 812 insertions(+), 14 deletions(-) create mode 100644 app/Livewire/Admin/ConfirmSaveEnv.php create mode 100644 app/Services/Env/EnvFileEditor.php create mode 100644 app/Services/Env/InvalidEnvContentException.php create mode 100644 resources/views/livewire/admin/confirm-save-env.blade.php create mode 100644 tests/Feature/Admin/EnvFileEditorTest.php diff --git a/.gitignore b/.gitignore index 4c90594..d96e697 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ .env .env.backup .env.production +# EnvFileEditor's own backups (App\Services\Env\EnvFileEditor::backup()) — a +# timestamped copy beside .env before every write from the console's raw +# editor. Never pruned automatically; see the warning on that page. +.env.bak-* .phpactor.json .phpunit.result.cache /.codex diff --git a/app/Livewire/Admin/ConfirmSaveEnv.php b/app/Livewire/Admin/ConfirmSaveEnv.php new file mode 100644 index 0000000..144275b --- /dev/null +++ b/app/Livewire/Admin/ConfirmSaveEnv.php @@ -0,0 +1,35 @@ +authorize('secrets.manage'); + } + + public function confirm(): void + { + $this->authorize('secrets.manage'); + $this->dispatch('env-save-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-save-env'); + } +} diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index 2c8bc94..3e6b199 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -3,6 +3,8 @@ namespace App\Livewire\Admin; use App\Livewire\Concerns\ConfirmsPassword; +use App\Services\Env\EnvFileEditor; +use App\Services\Env\InvalidEnvContentException; use App\Services\Secrets\SecretVault; use App\Support\ProvisioningSettings; use App\Support\Settings; @@ -41,11 +43,18 @@ use Throwable; * action, still checks its OWN capability (guardSecrets()/guardInfra()) * server-side; an Admin who can reach this page for the DNS zone still gets * 403 the moment they try to touch a vault entry. + * + * The .env editor (Part B) shares the secrets lock: it needs `secrets.manage` + * and the same confirmed password, because it is the one place on this page + * that can reach every credential the vault deliberately keeps write-only — + * see EnvFileEditor for the safeguards that make that survivable. */ #[Layout('layouts.admin')] class Integrations extends Component { - use ConfirmsPassword; + use ConfirmsPassword { + forgetPasswordConfirmation as private lockAgain; + } // Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect // and dispatch back into (secret-save-confirmed / secret-forget-confirmed). @@ -66,6 +75,16 @@ class Integrations extends Component public string $monitoringUrl = ''; + // .env editor. + public string $envContent = ''; + + /** + * Loaded lazily, on the first render after unlocking — never in mount(), + * so a merely-reachable page never pulls the whole credential file into + * component state for an operator who has not confirmed anything yet. + */ + public bool $envLoaded = false; + protected function confirmationGuard(): string { return 'operator'; @@ -178,12 +197,62 @@ class Integrations extends Component return str_replace('.', '_', $key); } + // ---- The .env editor — Part B. secrets.manage + confirmed password. ---- + + /** + * Validate, back up, write. EnvFileEditor does the actual work and the + * actual refusing; this only translates its one exception into a form + * error instead of a 500, and reports where the backup landed. + */ + public function saveEnv(): void + { + $this->guardSecrets(); + + try { + $backup = app(EnvFileEditor::class)->write($this->envContent); + } catch (InvalidEnvContentException $e) { + $this->addError('envContent', $e->invalidLine !== null + ? __('integrations.env_invalid', ['line' => $e->invalidLine]) + : __('integrations.env_empty')); + + return; + } + + // '' means there was no previous file to protect — EnvFileEditor's + // own docblock explains why that is not an error. + $this->dispatch('notify', message: __('integrations.env_saved', [ + 'backup' => $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file'), + ])); + } + + /** ConfirmSaveEnv dispatches this back (R23) — see that class. */ + #[On('env-save-confirmed')] + public function onEnvSaveConfirmed(): void + { + $this->saveEnv(); + } + + /** + * Overrides ConfirmsPassword's own method (aliased above to lockAgain) to + * also drop the Stripe check result — the render()-side check just below + * handles $envContent/$envLoaded, and handles it for BOTH ways a session + * can end up locked: this explicit click, and the confirmation window + * simply expiring with nobody clicking anything. A clear here alone would + * only ever cover the first. + */ + public function forgetPasswordConfirmation(): void + { + $this->lockAgain(); + + $this->check = null; + } + private function guardInfra(): void { $this->authorize('hosts.manage'); } - /** Capability AND a recently confirmed password, on every vault action. */ + /** Capability AND a recently confirmed password, on every vault/.env action. */ private function guardSecrets(): void { $this->authorize('secrets.manage'); @@ -197,6 +266,27 @@ class Integrations extends Component $canInfra = Gate::allows('hosts.manage'); $unlocked = $this->passwordRecentlyConfirmed(); + // Loaded here rather than in mount(): a page that is merely reachable + // must not already hold the whole credential file in component state + // for an operator who has not confirmed a password this session. + if ($canSecrets && $unlocked && ! $this->envLoaded) { + $this->envContent = app(EnvFileEditor::class)->read(); + $this->envLoaded = true; + } + + // The mirror case, and the one forgetPasswordConfirmation() alone + // does not cover: the confirmation window can also expire on its + // own, with nobody clicking "Wieder sperren" — $unlocked simply goes + // false on whatever the next render happens to be. Without this, the + // textarea disappears from the page but $envContent — the ENTIRE + // credential file — stays sitting in the component snapshot, reachable + // to anyone with the browser this was left open on (Codex review, + // P1). Checked on every render, not only the explicit lock action. + if (! $unlocked && $this->envLoaded) { + $this->envContent = ''; + $this->envLoaded = false; + } + return view('livewire.admin.integrations', [ 'canSecrets' => $canSecrets, 'canInfra' => $canInfra, @@ -207,6 +297,7 @@ class Integrations extends Component 'key' => $key, 'field' => self::field($key), 'label' => __($meta['label']), + 'envKey' => $meta['env_key'], 'testable' => isset($meta['check']), 'source' => $vault->source($key), 'outline' => $unlocked ? $vault->outline($key) : null, diff --git a/app/Services/Env/EnvFileEditor.php b/app/Services/Env/EnvFileEditor.php new file mode 100644 index 0000000..af8f85b --- /dev/null +++ b/app/Services/Env/EnvFileEditor.php @@ -0,0 +1,146 @@ +path !== '' ? $this->path : base_path('.env'); + } + + public function read(): string + { + return File::exists($this->path()) ? File::get($this->path()) : ''; + } + + /** + * The first line that is neither blank, nor a comment, nor KEY=value — + * or null when every line is one of those three things. 1-indexed, so it + * can be shown to an operator directly ("Zeile 42"). + */ + public function firstInvalidLine(string $content): ?int + { + foreach (preg_split('/\r\n|\r|\n/', $content) as $number => $line) { + if (! self::isValidLine($line)) { + return $number + 1; + } + } + + return null; + } + + public function isValid(string $content): bool + { + return $this->firstInvalidLine($content) === null; + } + + /** + * Validate, back up, then write — in that order, and never out of it. + * Content that fails validation is never backed up (nothing is about to + * change) and never written. + * + * @return string the backup path, so the caller can tell the operator + * exactly where the previous version landed + * + * @throws InvalidEnvContentException the content is empty, or does not parse + */ + public function write(string $content): string + { + if (trim($content) === '') { + throw InvalidEnvContentException::empty(); + } + + $line = $this->firstInvalidLine($content); + if ($line !== null) { + throw InvalidEnvContentException::atLine($line); + } + + $backup = $this->backup(); + File::put($this->path(), $content); + + return $backup; + } + + /** + * A timestamped copy of the CURRENT file, beside it — before whatever + * write() is about to do to the original. Microseconds in the name so two + * saves a second apart (a real thing to hit while testing this class, not + * only a hypothetical operator habit) do not collide and silently + * overwrite one another. + * + * Returns '' — never a path — when there is nothing to back up, i.e. the + * file does not exist yet. read() already treats a missing file as '' + * rather than an error (Codex review, P2); write() has to agree, or the + * very first save on a fresh installation would fail trying to copy a + * file that was never there instead of simply creating one. + */ + public function backup(): string + { + if (! File::exists($this->path())) { + return ''; + } + + $target = $this->path().'.bak-'.now()->format('Ymd-His-u'); + + if (! File::copy($this->path(), $target)) { + throw new \RuntimeException("Could not back up [{$this->path()}] before writing it."); + } + + return $target; + } + + private static function isValidLine(string $line): bool + { + $line = ltrim(rtrim($line, "\r\n")); + + if ($line === '' || str_starts_with($line, '#')) { + return true; + } + + return (bool) preg_match('/^[A-Za-z_][A-Za-z0-9_]*=.*$/', $line); + } +} diff --git a/app/Services/Env/InvalidEnvContentException.php b/app/Services/Env/InvalidEnvContentException.php new file mode 100644 index 0000000..e223ebc --- /dev/null +++ b/app/Services/Env/InvalidEnvContentException.php @@ -0,0 +1,35 @@ + 'services.stripe.secret', 'label' => 'secrets.item.stripe_secret', 'check' => StripeCheck::class, + 'env_key' => 'STRIPE_SECRET', ], 'dns.token' => [ 'config' => 'provisioning.dns.token', 'label' => 'secrets.item.dns_token', + 'env_key' => 'HETZNER_DNS_TOKEN', ], 'monitoring.token' => [ 'config' => 'services.monitoring.token', 'label' => 'secrets.item.monitoring_token', + 'env_key' => 'MONITORING_API_TOKEN', ], 'ssh.private_key' => [ 'config' => 'provisioning.ssh.private_key', 'label' => 'secrets.item.ssh_private_key', + // File-or-inline (see config/provisioning.php's $secretFrom) — the + // editor names both, since either can be the one actually in force. + 'env_key' => 'CLUPILOT_SSH_PRIVATE_KEY / CLUPILOT_SSH_PRIVATE_KEY_PATH', ], ]; diff --git a/lang/de/integrations.php b/lang/de/integrations.php index c32561c..48a90b4 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -33,4 +33,37 @@ return [ 'save_settings' => 'Einstellungen speichern', 'settings_saved' => 'Gespeichert. Der neue Wert gilt ab sofort.', + + // .env-Editor (Teil B). + 'env_title' => 'Erweitert: Serverdatei (.env)', + 'env_subtitle' => 'Alles, was oben kein eigenes Feld hat — roh, wie auf dem Server. Ohne diesen Editor bräuchte es dafür eine Shell.', + 'env_locked' => 'Gesperrt. Oben entsperren, um die Serverdatei zu sehen.', + + 'env_danger_title' => 'Diese Zeilen können die Konsole selbst aussperren', + 'env_danger_body' => 'Ein falscher Wert in einer dieser Zeilen macht die Konsole für Sie selbst unerreichbar: APP_KEY, DB_* (Datenbankverbindung), REDIS_* (Sitzungen, Cache, Warteschlange), SESSION_*. Nicht gesperrt — volle Kontrolle war die Entscheidung —, aber diese Zeilen verdienen einen zweiten Blick.', + + 'env_vault_title' => 'Von der Zugangsdaten-Verwaltung verwaltet', + 'env_vault_body' => 'SecretVault::get() bevorzugt einen hier hinterlegten Wert und weicht nur auf die Serverdatei aus, wenn keiner hinterlegt ist. Solange oben ein Wert hinterlegt ist, ändert die passende Zeile unten nichts — sonst widerspräche sich die Konsole selbst.', + 'env_vault_active' => 'hinterlegt — diese Zeile wirkt nicht', + 'env_vault_inactive' => 'nicht hinterlegt — diese Zeile gilt', + + 'env_label' => 'Inhalt von .env', + 'env_save' => 'Speichern', + + 'env_backup_title' => 'Sicherung', + 'env_backup_body' => 'Vor jedem Speichern wird eine Kopie neben .env abgelegt (Dateiname .env.bak-Zeitstempel). Sie werden nicht automatisch gelöscht.', + + 'env_restart_title' => 'Was Speichern nicht tut', + 'env_restart_body' => 'queue, queue-provisioning, scheduler und reverb lesen .env nur beim eigenen Start — ein hier gespeicherter Wert wirkt dort erst nach einem Neustart. Ohne ihn sieht es aus, als hätte der Editor nichts getan.', + 'env_config_clear' => 'Ist der Konfigurations-Cache aktiv, zusätzlich: docker compose exec -T app php artisan config:clear.', + + 'env_save_title' => '.env wirklich überschreiben?', + 'env_save_body' => 'Sie enthält jedes Zugangsdatum im System. Eine Sicherung wird zuvor angelegt — betroffene Dienste übernehmen die Änderung trotzdem erst nach einem Neustart.', + 'env_save_confirm' => 'Überschreiben', + 'env_saved' => 'Gespeichert. Sicherung: :backup. Betroffene Dienste übernehmen die Änderung erst nach einem Neustart.', + // Nur wenn keine .env vorher existierte — dann gibt es nichts zu sichern. + 'env_no_previous_file' => 'keine, da zuvor keine Datei existierte', + + 'env_invalid' => 'Zeile :line ist weder leer, noch ein Kommentar, noch SCHLÜSSEL=Wert. Nichts wurde gespeichert.', + 'env_empty' => 'Eine leere Datei würde jede Einstellung löschen. Nichts wurde gespeichert.', ]; diff --git a/lang/en/integrations.php b/lang/en/integrations.php index 86a7ec2..91643cd 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -33,4 +33,37 @@ return [ 'save_settings' => 'Save settings', 'settings_saved' => 'Saved. The new value applies immediately.', + + // .env editor (Part B). + 'env_title' => 'Advanced: server file (.env)', + 'env_subtitle' => 'Everything above with no field of its own — raw, as it sits on the server. Without this editor, that still needs a shell.', + 'env_locked' => 'Locked. Unlock above to see the server file.', + + 'env_danger_title' => 'These lines can lock you out of the console itself', + 'env_danger_body' => 'A wrong value on one of these lines makes the console unreachable for you too: APP_KEY, DB_* (the database connection), REDIS_* (sessions, cache, queue), SESSION_*. Not blocked — full control was the decision — but these lines are worth a second look.', + + 'env_vault_title' => 'Managed by credential storage', + 'env_vault_body' => 'SecretVault::get() prefers a value stored there and only falls back to the server file when none is stored. While a value is stored above, the matching line below changes nothing — otherwise the console would contradict itself.', + 'env_vault_active' => 'stored — this line has no effect', + 'env_vault_inactive' => 'not stored — this line applies', + + 'env_label' => 'Contents of .env', + 'env_save' => 'Save', + + 'env_backup_title' => 'Backup', + 'env_backup_body' => 'A copy is placed beside .env before every save (filename .env.bak-timestamp). Nothing deletes them automatically.', + + 'env_restart_title' => 'What saving does not do', + 'env_restart_body' => 'queue, queue-provisioning, scheduler and reverb only read .env at their own startup — a value saved here does not take effect there until they restart. Without a restart, it looks like the editor did nothing.', + 'env_config_clear' => 'With the config cache active, also run: docker compose exec -T app php artisan config:clear.', + + 'env_save_title' => 'Really overwrite .env?', + 'env_save_body' => 'It holds every credential in the system. A backup is made first — affected services still only pick up the change after a restart.', + 'env_save_confirm' => 'Overwrite', + 'env_saved' => 'Saved. Backup: :backup. Affected services pick up the change only after a restart.', + // Only when no .env existed before — then there is nothing to back up. + 'env_no_previous_file' => 'none, no file existed before this', + + 'env_invalid' => 'Line :line is neither blank, a comment, nor KEY=value. Nothing was saved.', + 'env_empty' => 'An empty file would erase every setting. Nothing was saved.', ]; diff --git a/resources/views/livewire/admin/confirm-save-env.blade.php b/resources/views/livewire/admin/confirm-save-env.blade.php new file mode 100644 index 0000000..fcc6657 --- /dev/null +++ b/resources/views/livewire/admin/confirm-save-env.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('integrations.env_save_title') }}

+

{{ __('integrations.env_save_body') }}

+
+
+
+ {{ __('common.cancel') }} + + {{ __('integrations.env_save_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/integrations.blade.php b/resources/views/livewire/admin/integrations.blade.php index b2a5305..9bdeecd 100644 --- a/resources/views/livewire/admin/integrations.blade.php +++ b/resources/views/livewire/admin/integrations.blade.php @@ -9,9 +9,9 @@ @endif {{-- The one lock for everything write-only on this page: every vault - entry below. Plain settings never sit behind this — they were never - gated by a password before, and nothing about merging the pages - makes them more dangerous. --}} + entry below, and the .env editor further down. Plain settings never + sit behind this — they were never gated by a password before, and + nothing about merging the pages makes them more dangerous. --}} @if ($canSecrets && ! $unlocked)

{{ __('secrets.locked_title') }}

@@ -180,4 +180,74 @@ @endif @endif + + {{-- Part B: the raw .env editor. Everything above has a field; everything + that does not still needs to be reachable from the console, or an + operator needing it needs a shell and this whole page misses the + point. Shares the lock above: secrets.manage AND a confirmed + password, because it is the one place on this page that can reach + every credential the vault otherwise keeps write-only. --}} + @if ($canSecrets) +
+
+

{{ __('integrations.env_title') }}

+

{{ __('integrations.env_subtitle') }}

+
+ + @if (! $unlocked) +

+ + {{ __('integrations.env_locked') }} +

+ @else + +

{{ __('integrations.env_danger_title') }}

+

{{ __('integrations.env_danger_body') }}

+
+ +
+

{{ __('integrations.env_vault_title') }}

+

{{ __('integrations.env_vault_body') }}

+
    + @foreach ($entries as $entry) +
  • + {{ $entry['envKey'] }} + + {{ $entry['source'] === 'stored' ? __('integrations.env_vault_active') : __('integrations.env_vault_inactive') }} + +
  • + @endforeach +
+
+ +
+ + + @error('envContent')

{{ $message }}

@enderror +
+ +
+ + {{ __('integrations.env_save') }} + +
+ +
+
+

{{ __('integrations.env_backup_title') }}

+

{{ __('integrations.env_backup_body') }}

+
+
+

{{ __('integrations.env_restart_title') }}

+

{{ __('integrations.env_restart_body') }}

+
docker compose restart queue queue-provisioning scheduler reverb
+

{{ __('integrations.env_config_clear') }}

+
+
+ @endif +
+ @endif diff --git a/tests/Feature/Admin/EnvFileEditorTest.php b/tests/Feature/Admin/EnvFileEditorTest.php new file mode 100644 index 0000000..352a0ec --- /dev/null +++ b/tests/Feature/Admin/EnvFileEditorTest.php @@ -0,0 +1,137 @@ +firstInvalidLine("APP_NAME=CluPilot\n\n# a comment\nDB_HOST=mariadb\n"))->toBeNull() + ->and($editor->isValid("APP_NAME=CluPilot\n"))->toBeTrue() + // A value may itself contain '=' — only the first one delimits the key. + ->and($editor->isValid('MIX=a=b=c'))->toBeTrue() + // A key may have an empty value. + ->and($editor->isValid('EMPTY_OK='))->toBeTrue(); + + // Neither blank, nor a comment, nor KEY=value — the exact line named in + // the task, and 1-indexed so it can be shown to an operator directly. + expect($editor->firstInvalidLine("APP_NAME=CluPilot\nthis is not env syntax\nDB_HOST=mariadb\n"))->toBe(2) + ->and($editor->isValid("APP_NAME=CluPilot\nthis is not env syntax\n"))->toBeFalse(); +}); + +it('refuses to write invalid content, and never touches the file', function () { + // Mutation target: an invalid file is refused rather than written. + $path = envEditorPath(); + File::put($path, "ORIGINAL=untouched\n"); + $editor = new EnvFileEditor($path); + + expect(fn () => $editor->write("ORIGINAL=untouched\nnot env syntax at all\n")) + ->toThrow(InvalidEnvContentException::class); + + // The file on disk is EXACTLY what it was before the rejected write — + // not merely "still has ORIGINAL in it somewhere". + expect(File::get($path))->toBe("ORIGINAL=untouched\n") + // And no backup was created either: nothing was about to change. + ->and(glob($path.'.bak-*'))->toBe([]); +}); + +it('refuses an empty file even though it is syntactically valid', function () { + $path = envEditorPath(); + File::put($path, "ORIGINAL=untouched\n"); + $editor = new EnvFileEditor($path); + + expect(fn () => $editor->write(''))->toThrow(InvalidEnvContentException::class) + ->and(fn () => $editor->write(" \n\n"))->toThrow(InvalidEnvContentException::class); + + expect(File::get($path))->toBe("ORIGINAL=untouched\n"); +}); + +it('backs up the previous content before writing the new content', function () { + // Mutation target: a backup really exists after a save. + $path = envEditorPath(); + File::put($path, "OLD_KEY=old-value\n"); + $editor = new EnvFileEditor($path); + + $backupPath = $editor->write("NEW_KEY=new-value\n"); + + expect(File::exists($backupPath))->toBeTrue() + ->and(File::get($backupPath))->toBe("OLD_KEY=old-value\n") + // The backup sits BESIDE the file, under the same name plus a suffix + // — not in some other directory an operator would have to be told + // about separately. + ->and(dirname($backupPath))->toBe(dirname($path)) + ->and(File::get($path))->toBe("NEW_KEY=new-value\n"); +}); + +it('does not collide when two writes land in the same second', function () { + $path = envEditorPath(); + File::put($path, "V=1\n"); + $editor = new EnvFileEditor($path); + + $first = $editor->write("V=2\n"); + $second = $editor->write("V=3\n"); + + expect($first)->not->toBe($second) + ->and(File::exists($first))->toBeTrue() + ->and(File::exists($second))->toBeTrue(); +}); + +it('creates the file when none existed before, without trying to back up something that was never there', function () { + // Codex review, P2: File::copy() on a source that does not exist throws, + // which made the first save on an installation with no .env yet fail + // outright instead of creating one. + $path = envEditorPath(); + File::delete($path); // envEditorPath() seeds it — deliberately undone here. + $editor = new EnvFileEditor($path); + + $backup = $editor->write("FRESH=value\n"); + + expect($backup)->toBe('') + ->and(File::get($path))->toBe("FRESH=value\n"); +}); + +it('answers empty rather than throwing when asked to back up a file that does not exist', function () { + $path = envEditorPath(); + File::delete($path); + + expect((new EnvFileEditor($path))->backup())->toBe(''); +}); + +it('defaults to base_path(.env) when no path is given', function () { + expect((new EnvFileEditor)->path())->toBe(base_path('.env')); +}); + +it('reads empty rather than failing when the file does not exist yet', function () { + $editor = new EnvFileEditor(envEditorPath()); + + expect($editor->read())->toBe(''); +}); diff --git a/tests/Feature/Admin/IntegrationsPageTest.php b/tests/Feature/Admin/IntegrationsPageTest.php index 932abd7..58960ca 100644 --- a/tests/Feature/Admin/IntegrationsPageTest.php +++ b/tests/Feature/Admin/IntegrationsPageTest.php @@ -1,10 +1,14 @@ instance(EnvFileEditor::class, new EnvFileEditor($path)); + + return $path; +} + +afterEach(function () { + foreach (glob(storage_path('framework/testing/env-file-editor-*')) as $leftover) { + @unlink($leftover); + } +}); + beforeEach(function () { config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); }); @@ -39,7 +65,8 @@ it('shows the settings sections but not the vault sections to an operator with o ->assertViewHas('canInfra', true) ->assertViewHas('canSecrets', false) ->assertSee(__('integrations.dns_zone')) - ->assertDontSee(__('secrets.item.stripe_secret')); + ->assertDontSee(__('secrets.item.stripe_secret')) + ->assertDontSee(__('integrations.env_title')); }); it('shows the vault sections but not the settings sections to an operator with only secrets.manage', function () { @@ -52,7 +79,8 @@ it('shows the vault sections but not the settings sections to an operator with o ->assertViewHas('canInfra', false) ->assertViewHas('canSecrets', true) ->assertDontSee(__('integrations.dns_zone')) - ->assertSee(__('secrets.item.stripe_secret')); + ->assertSee(__('secrets.item.stripe_secret')) + ->assertSee(__('integrations.env_title')); }); it('shows both halves to the Owner, who has both capabilities', function () { @@ -60,7 +88,8 @@ it('shows both halves to the Owner, who has both capabilities', function () { ->test(Integrations::class) ->assertOk() ->assertSee(__('integrations.dns_zone')) - ->assertSee(__('secrets.item.stripe_secret')); + ->assertSee(__('secrets.item.stripe_secret')) + ->assertSee(__('integrations.env_title')); }); // ---- The retired pages redirect rather than 404 a bookmark. ---- @@ -99,8 +128,9 @@ it('shows nothing until the password is confirmed', function () { ->assertViewHas('unlocked', false); }); -it('refuses every vault action while locked, not merely hiding the buttons', function () { +it('refuses every vault and env action while locked, not merely hiding the buttons', function () { $owner = operator('Owner'); + useTempEnvFile(); foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) { Livewire::actingAs($owner, 'operator') @@ -108,6 +138,11 @@ it('refuses every vault action while locked, not merely hiding the buttons', fun ->call($action, $arg) ->assertForbidden(); } + + Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->call('saveEnv') + ->assertForbidden(); }); it('does not let an operator with only hosts.manage touch a vault action', function () { @@ -147,16 +182,51 @@ it('never puts a stored secret into the component snapshot', function () { expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42'); }); -it('can be locked again without signing out', function () { +it('can be locked again without signing out, which also drops the .env content it was holding', function () { $owner = operator('Owner'); + useTempEnvFile(); Livewire::actingAs($owner, 'operator') ->test(Integrations::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->assertViewHas('unlocked', true) + ->assertSet('envLoaded', true) ->call('forgetPasswordConfirmation') - ->assertViewHas('unlocked', false); + ->assertViewHas('unlocked', false) + ->assertSet('envContent', '') + ->assertSet('envLoaded', false); +}); + +it('drops .env content from state when the confirmation window expires on its own, not only on an explicit lock', function () { + // Codex review, P1: nobody has to click "Wieder sperren" for the window + // to end — it simply ages out. Before this was fixed, $unlocked went + // false on the next render but $envContent — the whole credential file — + // stayed in the component snapshot, reachable to anyone left with the + // browser. Triggered here with an ordinary property set(), never + // forgetPasswordConfirmation() itself, specifically to prove the render() + // side of the fix and not the explicit-lock side already covered above. + $owner = operator('Owner'); + useTempEnvFile("REAL_SECRET_MARKER=abcdef\n"); + + $page = Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword'); + + expect($page->get('envContent'))->toContain('REAL_SECRET_MARKER=abcdef'); + + // ConfirmsPassword times its marker with a raw time(), which does not + // hear Carbon::setTestNow() (travel()/travelTo() would silently do + // nothing here) — so the window is aged out the same way it is set: + // by writing the marker directly, in the same session key format + // passwordConfirmationSessionKey() builds. + session(['auth.password_confirmed_at.operator.'.$owner->getAuthIdentifier() => time() - 999999]); + + $page->set('dnsZone', 'still-here') + ->assertViewHas('unlocked', false) + ->assertSet('envContent', '') + ->assertSet('envLoaded', false); }); it('does not open the save/forget confirmation without the capability', function () { @@ -275,3 +345,116 @@ it('does not let an operator with only secrets.manage save the settings half', f ->call('saveInfra') ->assertForbidden(); }); + +// ---- Part B: the raw .env editor. ---- + +it('does not open the .env confirm modal without secrets.manage', function () { + Livewire::actingAs(operator('Admin'), 'operator') + ->test(ConfirmSaveEnv::class) + ->assertForbidden(); +}); + +it('mutation target: refuses to save .env without a confirmed password even with secrets.manage', function () { + $owner = operator('Owner'); + useTempEnvFile(); + + Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('envContent', "NEW=value\n") + ->call('saveEnv') + ->assertForbidden(); +}); + +it('loads the real file content only after the password is confirmed', function () { + $owner = operator('Owner'); + useTempEnvFile("REAL_SECRET_MARKER=abcdef\n"); + + $locked = Livewire::actingAs($owner, 'operator')->test(Integrations::class); + expect($locked->get('envContent'))->toBe('') + ->and($locked->get('envLoaded'))->toBeFalse(); + + $unlocked = Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword'); + + expect($unlocked->get('envContent'))->toContain('REAL_SECRET_MARKER=abcdef') + ->and($unlocked->get('envLoaded'))->toBeTrue(); +}); + +it('marks which .env keys the vault currently overrides', function () { + $owner = operator('Owner'); + useTempEnvFile(); + app(SecretVault::class)->put('stripe.secret', 'sk_live_MARK1234567', $owner); + + $page = Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword'); + + $page->assertSee('STRIPE_SECRET') + ->assertSee(__('integrations.env_vault_active')) + ->assertSee(__('integrations.env_vault_inactive')); +}); + +it('mutation target: refuses an invalid .env body and writes nothing', function () { + $owner = operator('Owner'); + $path = useTempEnvFile("ORIGINAL=untouched\n"); + + Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->set('envContent', "ORIGINAL=untouched\nthis is not env syntax\n") + ->call('saveEnv') + ->assertHasErrors('envContent'); + + expect(File::get($path))->toBe("ORIGINAL=untouched\n") + ->and(glob($path.'.bak-*'))->toBe([]); +}); + +it('mutation target: a backup really exists after saving valid .env content', function () { + $owner = operator('Owner'); + $path = useTempEnvFile("OLD=value\n"); + + Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->set('envContent', "NEW=value\n") + ->call('saveEnv') + ->assertHasNoErrors(); + + $backups = glob($path.'.bak-*'); + + expect(File::get($path))->toBe("NEW=value\n") + ->and($backups)->not->toBe([]) + ->and(File::get($backups[0]))->toBe("OLD=value\n"); +}); + +it('confirms an .env save through the modal without writing anything itself', function () { + $owner = operator('Owner'); + $path = useTempEnvFile("OLD=value\n"); + + Livewire::actingAs($owner, 'operator') + ->test(ConfirmSaveEnv::class) + ->call('confirm') + ->assertDispatched('env-save-confirmed'); + + expect(File::get($path))->toBe("OLD=value\n"); +}); + +it('saves .env once the page receives the confirmed event', function () { + $owner = operator('Owner'); + $path = useTempEnvFile("OLD=value\n"); + + Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->set('envContent', "CONFIRMED=value\n") + ->call('onEnvSaveConfirmed') + ->assertHasNoErrors(); + + expect(File::get($path))->toBe("CONFIRMED=value\n"); +});