Add a raw .env editor to the Integrations page, with a net under it
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 <noreply@anthropic.com>feat/granted-plans
parent
3fadaa14b0
commit
90131f088a
|
|
@ -3,6 +3,10 @@
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.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
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
/.codex
|
/.codex
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation before .env — the file that holds every credential in the
|
||||||
|
* system — is overwritten (R23).
|
||||||
|
*
|
||||||
|
* The content being written lives only in Integrations' own $envContent; this
|
||||||
|
* modal never sees it and cannot mutate anything itself. Confirming
|
||||||
|
* dispatches back to the page component, whose saveEnv() keeps its own
|
||||||
|
* guardSecrets() (secrets.manage + a recently confirmed password) unchanged —
|
||||||
|
* same pattern as ConfirmSaveSecret/ConfirmForgetSecret.
|
||||||
|
*/
|
||||||
|
class ConfirmSaveEnv extends ModalComponent
|
||||||
|
{
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
namespace App\Livewire\Admin;
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
use App\Livewire\Concerns\ConfirmsPassword;
|
use App\Livewire\Concerns\ConfirmsPassword;
|
||||||
|
use App\Services\Env\EnvFileEditor;
|
||||||
|
use App\Services\Env\InvalidEnvContentException;
|
||||||
use App\Services\Secrets\SecretVault;
|
use App\Services\Secrets\SecretVault;
|
||||||
use App\Support\ProvisioningSettings;
|
use App\Support\ProvisioningSettings;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
|
|
@ -41,11 +43,18 @@ use Throwable;
|
||||||
* action, still checks its OWN capability (guardSecrets()/guardInfra())
|
* action, still checks its OWN capability (guardSecrets()/guardInfra())
|
||||||
* server-side; an Admin who can reach this page for the DNS zone still gets
|
* 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.
|
* 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')]
|
#[Layout('layouts.admin')]
|
||||||
class Integrations extends Component
|
class Integrations extends Component
|
||||||
{
|
{
|
||||||
use ConfirmsPassword;
|
use ConfirmsPassword {
|
||||||
|
forgetPasswordConfirmation as private lockAgain;
|
||||||
|
}
|
||||||
|
|
||||||
// Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
|
// Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
|
||||||
// and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
|
// and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
|
||||||
|
|
@ -66,6 +75,16 @@ class Integrations extends Component
|
||||||
|
|
||||||
public string $monitoringUrl = '';
|
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
|
protected function confirmationGuard(): string
|
||||||
{
|
{
|
||||||
return 'operator';
|
return 'operator';
|
||||||
|
|
@ -178,12 +197,62 @@ class Integrations extends Component
|
||||||
return str_replace('.', '_', $key);
|
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
|
private function guardInfra(): void
|
||||||
{
|
{
|
||||||
$this->authorize('hosts.manage');
|
$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
|
private function guardSecrets(): void
|
||||||
{
|
{
|
||||||
$this->authorize('secrets.manage');
|
$this->authorize('secrets.manage');
|
||||||
|
|
@ -197,6 +266,27 @@ class Integrations extends Component
|
||||||
$canInfra = Gate::allows('hosts.manage');
|
$canInfra = Gate::allows('hosts.manage');
|
||||||
$unlocked = $this->passwordRecentlyConfirmed();
|
$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', [
|
return view('livewire.admin.integrations', [
|
||||||
'canSecrets' => $canSecrets,
|
'canSecrets' => $canSecrets,
|
||||||
'canInfra' => $canInfra,
|
'canInfra' => $canInfra,
|
||||||
|
|
@ -207,6 +297,7 @@ class Integrations extends Component
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
'field' => self::field($key),
|
'field' => self::field($key),
|
||||||
'label' => __($meta['label']),
|
'label' => __($meta['label']),
|
||||||
|
'envKey' => $meta['env_key'],
|
||||||
'testable' => isset($meta['check']),
|
'testable' => isset($meta['check']),
|
||||||
'source' => $vault->source($key),
|
'source' => $vault->source($key),
|
||||||
'outline' => $unlocked ? $vault->outline($key) : null,
|
'outline' => $unlocked ? $vault->outline($key) : null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Env;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A raw editor for the file that holds every credential this installation
|
||||||
|
* has — .env itself.
|
||||||
|
*
|
||||||
|
* Exists because "everything the console has no field for" is still real:
|
||||||
|
* MAIL_*, DB_*, APP_KEY, whichever third-party key nobody has built a form
|
||||||
|
* for yet. Without this, an operator needing one of those needs a shell
|
||||||
|
* anyway, and the rest of the console page (App\Livewire\Admin\Integrations)
|
||||||
|
* would not have removed that need — only moved most of it.
|
||||||
|
*
|
||||||
|
* The net, not just the warning — three rules, because the obvious
|
||||||
|
* alternative is a page that can brick the installation with no way back
|
||||||
|
* through that same page:
|
||||||
|
*
|
||||||
|
* 1. VALIDATE BEFORE WRITING. write() never touches the file before checking
|
||||||
|
* the new content first. A line that is neither blank, nor a comment, nor
|
||||||
|
* KEY=value is rejected outright — see isValidLine().
|
||||||
|
* 2. An EMPTY file is rejected too, on top of the syntax check. Zero lines is
|
||||||
|
* syntactically valid by that rule (there is nothing to object to) and
|
||||||
|
* would still erase every setting in the file — worth refusing even though
|
||||||
|
* nothing asked for this check by name.
|
||||||
|
* 3. BACK UP BEFORE EVERY WRITE that actually happens: a timestamped copy of
|
||||||
|
* the file about to be REPLACED, written beside it, before the new content
|
||||||
|
* lands. Nothing here prunes old backups — App\Livewire\Admin\Integrations
|
||||||
|
* says so, in the same sentence that tells an operator where they land.
|
||||||
|
*
|
||||||
|
* Deliberately NOT built on vlucas/phpdotenv's own parser (already a Laravel
|
||||||
|
* dependency): that parser is more permissive than the rule this class
|
||||||
|
* documents — multiline values, `export KEY=`, variable interpolation — and
|
||||||
|
* borrowing it here would validate against a different, looser grammar than
|
||||||
|
* the one an operator is actually told about. This class enforces exactly the
|
||||||
|
* rule in its own docblock and nothing more.
|
||||||
|
*/
|
||||||
|
final class EnvFileEditor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $path Empty string means base_path('.env') — the
|
||||||
|
* installation's real file. Overridden in tests so
|
||||||
|
* nothing here ever has to write the one on this
|
||||||
|
* dev machine to be exercised.
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly string $path = '') {}
|
||||||
|
|
||||||
|
public function path(): string
|
||||||
|
{
|
||||||
|
return $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Env;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by EnvFileEditor::write() when the content handed to it must not be
|
||||||
|
* written — either a line that does not parse as env syntax, or content that
|
||||||
|
* is empty and would erase every setting in the file.
|
||||||
|
*
|
||||||
|
* Never thrown for anything already written: write() checks before it touches
|
||||||
|
* the file, never after — see its own docblock for why that order is the
|
||||||
|
* whole point of this class.
|
||||||
|
*/
|
||||||
|
final class InvalidEnvContentException extends RuntimeException
|
||||||
|
{
|
||||||
|
// Not $line: Exception already declares that property (the line the
|
||||||
|
// exception was THROWN from), non-readonly — redeclaring it readonly
|
||||||
|
// here is a fatal error, not merely a shadowing warning.
|
||||||
|
private function __construct(string $message, public readonly ?int $invalidLine)
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function atLine(int $line): self
|
||||||
|
{
|
||||||
|
return new self("Line {$line} is not blank, a comment, or KEY=value.", $line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function empty(): self
|
||||||
|
{
|
||||||
|
return new self('Refusing to write an empty .env file.', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,14 @@ final class SecretVault
|
||||||
* is stored. Where there is none, no test button is offered — a button that
|
* is stored. Where there is none, no test button is offered — a button that
|
||||||
* silently checks something else is worse than no button.
|
* silently checks something else is worse than no button.
|
||||||
*
|
*
|
||||||
|
* `env_key` is the literal .env variable name the raw editor on the console
|
||||||
|
* page (App\Livewire\Admin\Integrations) marks as vault-managed — shown so
|
||||||
|
* an operator editing that file directly can see why a changed line has no
|
||||||
|
* visible effect (get() prefers a stored row over config() every time; see
|
||||||
|
* the docblock above). Which console SECTION an entry belongs beside is not
|
||||||
|
* metadata here — Integrations' own view picks each entry up by key, so the
|
||||||
|
* grouping lives in one place, the template, rather than two.
|
||||||
|
*
|
||||||
* Deliberately NOT here: STRIPE_WEBHOOK_SECRET. It is read on every
|
* Deliberately NOT here: STRIPE_WEBHOOK_SECRET. It is read on every
|
||||||
* incoming payment event, and a database problem would turn signature
|
* incoming payment event, and a database problem would turn signature
|
||||||
* verification into a silent failure. It stays in the server file.
|
* verification into a silent failure. It stays in the server file.
|
||||||
|
|
@ -70,18 +78,24 @@ final class SecretVault
|
||||||
'config' => 'services.stripe.secret',
|
'config' => 'services.stripe.secret',
|
||||||
'label' => 'secrets.item.stripe_secret',
|
'label' => 'secrets.item.stripe_secret',
|
||||||
'check' => StripeCheck::class,
|
'check' => StripeCheck::class,
|
||||||
|
'env_key' => 'STRIPE_SECRET',
|
||||||
],
|
],
|
||||||
'dns.token' => [
|
'dns.token' => [
|
||||||
'config' => 'provisioning.dns.token',
|
'config' => 'provisioning.dns.token',
|
||||||
'label' => 'secrets.item.dns_token',
|
'label' => 'secrets.item.dns_token',
|
||||||
|
'env_key' => 'HETZNER_DNS_TOKEN',
|
||||||
],
|
],
|
||||||
'monitoring.token' => [
|
'monitoring.token' => [
|
||||||
'config' => 'services.monitoring.token',
|
'config' => 'services.monitoring.token',
|
||||||
'label' => 'secrets.item.monitoring_token',
|
'label' => 'secrets.item.monitoring_token',
|
||||||
|
'env_key' => 'MONITORING_API_TOKEN',
|
||||||
],
|
],
|
||||||
'ssh.private_key' => [
|
'ssh.private_key' => [
|
||||||
'config' => 'provisioning.ssh.private_key',
|
'config' => 'provisioning.ssh.private_key',
|
||||||
'label' => 'secrets.item.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',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,37 @@ return [
|
||||||
|
|
||||||
'save_settings' => 'Einstellungen speichern',
|
'save_settings' => 'Einstellungen speichern',
|
||||||
'settings_saved' => 'Gespeichert. Der neue Wert gilt ab sofort.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,37 @@ return [
|
||||||
|
|
||||||
'save_settings' => 'Save settings',
|
'save_settings' => 'Save settings',
|
||||||
'settings_saved' => 'Saved. The new value applies immediately.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<div class="rounded-lg bg-surface p-6">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
|
||||||
|
<x-ui.icon name="alert-triangle" class="size-5" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-base font-semibold text-ink">{{ __('integrations.env_save_title') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_save_body') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||||
|
<x-ui.button variant="danger" wire:click="confirm" wire:loading.attr="disabled">
|
||||||
|
{{ __('integrations.env_save_confirm') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- The one lock for everything write-only on this page: every vault
|
{{-- The one lock for everything write-only on this page: every vault
|
||||||
entry below. Plain settings never sit behind this — they were never
|
entry below, and the .env editor further down. Plain settings never
|
||||||
gated by a password before, and nothing about merging the pages
|
sit behind this — they were never gated by a password before, and
|
||||||
makes them more dangerous. --}}
|
nothing about merging the pages makes them more dangerous. --}}
|
||||||
@if ($canSecrets && ! $unlocked)
|
@if ($canSecrets && ! $unlocked)
|
||||||
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||||
<h2 class="font-semibold text-ink">{{ __('secrets.locked_title') }}</h2>
|
<h2 class="font-semibold text-ink">{{ __('secrets.locked_title') }}</h2>
|
||||||
|
|
@ -180,4 +180,74 @@
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@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)
|
||||||
|
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-ink">{{ __('integrations.env_title') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! $unlocked)
|
||||||
|
<p class="flex items-center gap-1.5 text-sm text-muted">
|
||||||
|
<x-ui.icon name="lock" class="size-4" />
|
||||||
|
{{ __('integrations.env_locked') }}
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
|
<x-ui.alert variant="danger">
|
||||||
|
<p class="font-semibold">{{ __('integrations.env_danger_title') }}</p>
|
||||||
|
<p class="mt-1">{{ __('integrations.env_danger_body') }}</p>
|
||||||
|
</x-ui.alert>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
||||||
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_vault_title') }}</p>
|
||||||
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_vault_body') }}</p>
|
||||||
|
<ul class="mt-3 space-y-1.5">
|
||||||
|
@foreach ($entries as $entry)
|
||||||
|
<li class="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
|
<span class="font-mono text-xs text-body">{{ $entry['envKey'] }}</span>
|
||||||
|
<span class="text-xs {{ $entry['source'] === 'stored' ? 'font-medium text-warning' : 'text-muted' }}">
|
||||||
|
{{ $entry['source'] === 'stored' ? __('integrations.env_vault_active') : __('integrations.env_vault_inactive') }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="envContent" class="block text-sm font-medium text-body">{{ __('integrations.env_label') }}</label>
|
||||||
|
<textarea id="envContent" name="envContent" rows="18" autocomplete="off" spellcheck="false"
|
||||||
|
wire:model="envContent"
|
||||||
|
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"></textarea>
|
||||||
|
@error('envContent')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<x-ui.button variant="primary"
|
||||||
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-save-env' })">
|
||||||
|
{{ __('integrations.env_save') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
||||||
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_backup_title') }}</p>
|
||||||
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_backup_body') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
||||||
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_restart_title') }}</p>
|
||||||
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_restart_body') }}</p>
|
||||||
|
<pre class="mt-2 overflow-x-auto rounded border border-line bg-surface px-3 py-2 font-mono text-[11px] text-body">docker compose restart queue queue-provisioning scheduler reverb</pre>
|
||||||
|
<p class="mt-2 text-xs text-muted">{{ __('integrations.env_config_clear') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Env\EnvFileEditor;
|
||||||
|
use App\Services\Env\InvalidEnvContentException;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EnvFileEditor is the net under App\Livewire\Admin\Integrations' raw .env
|
||||||
|
* editor (Part B of the integrations-page work): validate before writing,
|
||||||
|
* back up before every write that actually happens, never touch a file that
|
||||||
|
* failed either check.
|
||||||
|
*
|
||||||
|
* Every test here points the editor at a throwaway path under
|
||||||
|
* storage/framework/testing — never base_path('.env'), the real file this dev
|
||||||
|
* machine boots from.
|
||||||
|
*/
|
||||||
|
function envEditorPath(): string
|
||||||
|
{
|
||||||
|
$path = storage_path('framework/testing/env-file-editor-'.Str::random(12).'.env');
|
||||||
|
File::ensureDirectoryExists(dirname($path));
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
// Backups are deliberately never pruned by the class under test (that is
|
||||||
|
// the point — see the console page's own warning) — but a test run is
|
||||||
|
// not the installation the warning is about, so it cleans up after itself.
|
||||||
|
foreach (glob(storage_path('framework/testing/env-file-editor-*')) as $leftover) {
|
||||||
|
@unlink($leftover);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts blank lines, comments, and KEY=value, and nothing else', function () {
|
||||||
|
$editor = new EnvFileEditor(envEditorPath());
|
||||||
|
|
||||||
|
expect($editor->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('');
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Livewire\Admin\ConfirmForgetSecret;
|
use App\Livewire\Admin\ConfirmForgetSecret;
|
||||||
|
use App\Livewire\Admin\ConfirmSaveEnv;
|
||||||
use App\Livewire\Admin\ConfirmSaveSecret;
|
use App\Livewire\Admin\ConfirmSaveSecret;
|
||||||
use App\Livewire\Admin\Integrations;
|
use App\Livewire\Admin\Integrations;
|
||||||
|
use App\Services\Env\EnvFileEditor;
|
||||||
use App\Services\Secrets\SecretVault;
|
use App\Services\Secrets\SecretVault;
|
||||||
use App\Support\ProvisioningSettings;
|
use App\Support\ProvisioningSettings;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,10 +20,32 @@ use Livewire\Livewire;
|
||||||
* of their coverage is ported here unchanged in substance.
|
* of their coverage is ported here unchanged in substance.
|
||||||
*
|
*
|
||||||
* New in this file: the two capabilities are independently checked (an
|
* New in this file: the two capabilities are independently checked (an
|
||||||
* operator with only one of them sees only the matching half), and the
|
* operator with only one of them sees only the matching half), the redirects
|
||||||
* redirects off the two retired routes. The raw .env editor gets its own
|
* off the two retired routes, and Part B — the raw .env editor.
|
||||||
* coverage once it lands on this page.
|
|
||||||
*/
|
*/
|
||||||
|
function envEditorPathFor(string $seed = ''): string
|
||||||
|
{
|
||||||
|
$path = storage_path('framework/testing/env-file-editor-'.Str::random(12).'.env');
|
||||||
|
File::ensureDirectoryExists(dirname($path));
|
||||||
|
File::put($path, $seed !== '' ? $seed : "APP_NAME=CluPilot\nAPP_KEY=base64:test\nDB_HOST=mariadb\n");
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTempEnvFile(string $seed = ''): string
|
||||||
|
{
|
||||||
|
$path = envEditorPathFor($seed);
|
||||||
|
app()->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 () {
|
beforeEach(function () {
|
||||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
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('canInfra', true)
|
||||||
->assertViewHas('canSecrets', false)
|
->assertViewHas('canSecrets', false)
|
||||||
->assertSee(__('integrations.dns_zone'))
|
->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 () {
|
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('canInfra', false)
|
||||||
->assertViewHas('canSecrets', true)
|
->assertViewHas('canSecrets', true)
|
||||||
->assertDontSee(__('integrations.dns_zone'))
|
->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 () {
|
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)
|
->test(Integrations::class)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee(__('integrations.dns_zone'))
|
->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. ----
|
// ---- 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);
|
->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');
|
$owner = operator('Owner');
|
||||||
|
useTempEnvFile();
|
||||||
|
|
||||||
foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) {
|
foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) {
|
||||||
Livewire::actingAs($owner, 'operator')
|
Livewire::actingAs($owner, 'operator')
|
||||||
|
|
@ -108,6 +138,11 @@ it('refuses every vault action while locked, not merely hiding the buttons', fun
|
||||||
->call($action, $arg)
|
->call($action, $arg)
|
||||||
->assertForbidden();
|
->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 () {
|
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');
|
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');
|
$owner = operator('Owner');
|
||||||
|
useTempEnvFile();
|
||||||
|
|
||||||
Livewire::actingAs($owner, 'operator')
|
Livewire::actingAs($owner, 'operator')
|
||||||
->test(Integrations::class)
|
->test(Integrations::class)
|
||||||
->set('confirmablePassword', 'password')
|
->set('confirmablePassword', 'password')
|
||||||
->call('confirmPassword')
|
->call('confirmPassword')
|
||||||
->assertViewHas('unlocked', true)
|
->assertViewHas('unlocked', true)
|
||||||
|
->assertSet('envLoaded', true)
|
||||||
->call('forgetPasswordConfirmation')
|
->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 () {
|
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')
|
->call('saveInfra')
|
||||||
->assertForbidden();
|
->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");
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue