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))); // saveEnv() now reads/writes UpdateChannel state (config:clear + a // restart request) — the same shared storage/app/deploy files // UpdateButtonTest.php exercises. Cleared here for the same reason that // file clears it in its own beforeEach: PHPUnit/Pest run every test file // in one process, so a leftover status or request from whichever file ran // last would otherwise decide whether these tests see an agent as alive. File::deleteDirectory(storage_path('app/deploy')); }); // ---- Reachability: either capability opens the page, neither 403s it. ---- it('is not reachable with neither hosts.manage nor secrets.manage', function () { Livewire::actingAs(operator('Support'), 'operator') ->test(Integrations::class) ->assertForbidden(); }); it('shows the settings sections but not the vault sections to an operator with only hosts.manage', function () { Livewire::actingAs(operator('Admin'), 'operator') ->test(Integrations::class) ->assertOk() ->assertViewHas('canInfra', true) ->assertViewHas('canSecrets', false) ->assertSee(__('integrations.dns_zone')) ->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 () { // The page is tabbed now, so each half is asserted on the tab it lives on. // What is being checked has not changed: a capability decides what is // rendered, never which tab happens to be open. $operator = operator('Support'); $operator->givePermissionTo('secrets.manage'); $page = Livewire::actingAs($operator, 'operator')->test(Integrations::class); $page->assertOk() ->assertViewHas('canInfra', false) ->assertViewHas('canSecrets', true) ->assertDontSee(__('integrations.dns_zone')) ->assertSee(__('secrets.item.stripe_secret')); $page->set('tab', 'env')->assertSee(__('integrations.env_title')); }); it('shows both halves to the Owner, who has both capabilities', function () { $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Integrations::class); $page->assertOk() ->assertSee(__('integrations.dns_zone')) ->assertSee(__('secrets.item.stripe_secret')); $page->set('tab', 'env')->assertSee(__('integrations.env_title')); }); // ---- The retired pages redirect rather than 404 a bookmark. ---- it('redirects the retired /admin/secrets route to /admin/integrations', function () { $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.secrets')) ->assertRedirect(route('admin.integrations')) ->assertStatus(301); }); it('redirects the retired /admin/infrastructure route to /admin/integrations', function () { $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.infrastructure')) ->assertRedirect(route('admin.integrations')) ->assertStatus(301); }); // ---- Vault entries: ported from the former SecretsPageTest. ---- it('shows the no-key banner for a malformed SECRETS_KEY, not only a missing one', function () { config()->set('admin_access.secrets_key', 'zu-kurz'); Livewire::actingAs(operator('Owner'), 'operator') ->test(Integrations::class) ->assertSee(__('secrets.no_key')); }); it('shows nothing until the password is confirmed', function () { $owner = operator('Owner'); app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner); Livewire::actingAs($owner, 'operator')->test(Integrations::class) ->assertSee(__('secrets.locked_title')) ->assertDontSee('9999') ->assertViewHas('unlocked', false); }); 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') ->test(Integrations::class) ->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 () { Livewire::actingAs(operator('Admin'), 'operator') ->test(Integrations::class) ->call('save', 'stripe.secret') ->assertForbidden(); }); it('unlocks with the password, then stores and clears the entered value', function () { $owner = operator('Owner'); $page = Livewire::actingAs($owner, 'operator') ->test(Integrations::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->assertHasNoErrors() ->assertViewHas('unlocked', true); $page->set('entered.stripe_secret', 'sk_live_NEWKEY1234') ->call('save', 'stripe.secret') ->assertHasNoErrors(); expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_NEWKEY1234') ->and($page->get('entered')['stripe_secret'])->toBe(''); }); it('never puts a stored secret into the component snapshot', function () { $owner = operator('Owner'); app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner); $page = Livewire::actingAs($owner, 'operator') ->test(Integrations::class) ->set('confirmablePassword', 'password') ->call('confirmPassword'); expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42'); }); 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) ->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 () { Livewire::actingAs(operator('Admin'), 'operator') ->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret']) ->assertForbidden(); Livewire::actingAs(operator('Admin'), 'operator') ->test(ConfirmForgetSecret::class, ['key' => 'stripe.secret']) ->assertForbidden(); }); it('confirms a save through the modal without storing anything itself', function () { $owner = operator('Owner'); Livewire::actingAs($owner, 'operator') ->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret']) ->assertSee(__('secrets.item.stripe_secret')) ->call('confirm') ->assertDispatched('secret-save-confirmed', key: 'stripe.secret'); expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse(); }); it('saves the typed value once the page receives the confirmed event', function () { $owner = operator('Owner'); Livewire::actingAs($owner, 'operator') ->test(Integrations::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->set('entered.stripe_secret', 'sk_live_VIACONFIRM1') ->call('onSaveConfirmed', 'stripe.secret') ->assertHasNoErrors(); expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_VIACONFIRM1'); }); it('forgets the stored value once the page receives the confirmed event', function () { $owner = operator('Owner'); app(SecretVault::class)->put('stripe.secret', 'sk_live_TOFORGET999', $owner); Livewire::actingAs($owner, 'operator') ->test(Integrations::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->call('onForgetConfirmed', 'stripe.secret'); expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse(); }); it('stores the SSH identity with its internal newlines intact', function () { $owner = operator('Owner'); $pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nabcdef\nghijkl\n-----END OPENSSH PRIVATE KEY-----"; Livewire::actingAs($owner, 'operator') ->test(Integrations::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->set('entered.ssh_private_key', $pem) ->call('save', 'ssh.private_key') ->assertHasNoErrors(); expect(app(SecretVault::class)->get('ssh.private_key'))->toBe($pem); }); it('binds the form to a key Livewire can actually write to', function () { expect(Integrations::field('stripe.secret'))->toBe('stripe_secret') ->and(str_contains(Integrations::field('stripe.secret'), '.'))->toBeFalse(); }); // ---- Plain settings: ported from the former InfrastructureSettingsTest. ---- it('loads the .env-derived value when nothing has been saved from the console yet', function () { // The premise has to be established now: a migration moves customer // instances onto their own zone on a fresh install, so "nothing saved" is // no longer the state a new database is in. App\Support\Settings::forget('provisioning.dns_zone'); config()->set('provisioning.dns.zone', 'env-zone.example'); Livewire::actingAs(operator('Owner'), 'operator') ->test(Integrations::class) ->assertSet('dnsZone', 'env-zone.example'); }); it('saves every settings field, and the consumer reads the saved value back', function () { Livewire::actingAs(operator('Owner'), 'operator') ->test(Integrations::class) ->set('dnsZone', 'console-zone.example') ->set('wgEndpoint', 'vpn.example:51820') ->set('wgHubPubkey', 'hubpubkey==') ->set('traefikDynamicPath', '/etc/traefik/dynamic') ->set('sshPublicKey', 'ssh-ed25519 AAAAC3 clupilot') ->set('monitoringUrl', 'http://kuma-bridge:8080') ->call('saveInfra') ->assertHasNoErrors(); expect(ProvisioningSettings::dnsZone())->toBe('console-zone.example') ->and(ProvisioningSettings::wgEndpoint())->toBe('vpn.example:51820') ->and(ProvisioningSettings::wgHubPublicKey())->toBe('hubpubkey==') ->and(ProvisioningSettings::traefikDynamicPath())->toBe('/etc/traefik/dynamic') ->and(ProvisioningSettings::sshPublicKey())->toBe('ssh-ed25519 AAAAC3 clupilot') ->and(ProvisioningSettings::monitoringUrl())->toBe('http://kuma-bridge:8080'); }); it('refuses a monitoring URL that is not a URL', function () { Livewire::actingAs(operator('Owner'), 'operator') ->test(Integrations::class) ->set('monitoringUrl', 'not a url') ->call('saveInfra') ->assertHasErrors('monitoringUrl'); }); it('does not let an operator with only secrets.manage save the settings half', function () { $operator = operator('Support'); $operator->givePermissionTo('secrets.manage'); Livewire::actingAs($operator, 'operator') ->test(Integrations::class) ->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') ->set('tab', 'env'); $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"); });