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"); +});