fix: alert mail on queue, SMTP reset, cert/uptime auto-probe, pill heights, XSS
Batch of user-reported fixes + two findings from the nav-split audit.
Bugs:
- Alerts never e-mailed. AppServiceProvider::applyMailSettings() ran only
at boot; the long-lived QUEUE worker booted once, so SMTP configured
after worker start (the normal case) meant queued alert/reset mail used
the stale/log mailer. Re-apply the DB SMTP config before every job
(Queue::before) so queued mail always sends with the current settings.
- SMTP could not be fully reset. Add Email::clearConfig() (+ a Reset
button + Setting::forget) that removes every mail_* setting, reverting
the panel to the safe default (log) mailer.
- New cert endpoint / uptime check showed no validity/status until a
manual refresh. Certs\Index::addEndpoint + Health\Index::addCheck now
probe the just-added row immediately.
Audit findings (096ba3c..HEAD):
- HIGH stored/DOM XSS: the per-server Terminal tab rendered the server
name inside an Alpine x-text JS sink (`x-text="title || '{{ name }}'"`);
Blade escaping does not protect that context. Move the fallback to a
data-attribute read as a plain string (server-terminal + the host
terminal for consistency).
- LOW: /terminal is now admin-only, but the command-palette `g t` leader
key still sent every role there → 403. Drop it from the static map;
render it as a role-filtered nav entry (admins only).
UI:
- status-pill is h-8 now (the small-button height) so a status pill and
the action buttons in the same row line up (Services/Certs/Docker/…).
748 tests (new clearConfig test); R12-verified (uniform pill/button
heights). Marketing docs at ~/clusev-site reworded to remove the
contradictory domain/CLUSEV_DOMAIN install note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
parent
b4c309bd54
commit
4d349eebb9
|
|
@ -79,7 +79,7 @@ class Index extends Component
|
||||||
$this->ready = true;
|
$this->ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addEndpoint(): void
|
public function addEndpoint(CertService $certs): void
|
||||||
{
|
{
|
||||||
$this->gate();
|
$this->gate();
|
||||||
|
|
||||||
|
|
@ -90,8 +90,19 @@ class Index extends Component
|
||||||
'port' => ['required', 'integer', 'min:1', 'max:65535'],
|
'port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
CertEndpoint::create($data);
|
$ep = CertEndpoint::create($data);
|
||||||
$this->audit('cert.endpoint_add', $data['host'].':'.$data['port']);
|
$this->audit('cert.endpoint_add', $data['host'].':'.$data['port']);
|
||||||
|
|
||||||
|
// Probe the new endpoint right away so its validity shows immediately (not only after a
|
||||||
|
// manual refresh / the next scheduled scan).
|
||||||
|
try {
|
||||||
|
$res = $certs->check($ep->host, $ep->port);
|
||||||
|
$res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error';
|
||||||
|
$this->checks[$ep->uuid] = $res;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
$this->reset('label', 'host');
|
$this->reset('label', 'host');
|
||||||
$this->port = 443;
|
$this->port = 443;
|
||||||
$this->dispatch('notify', message: __('certs.added', ['host' => $data['host']]));
|
$this->dispatch('notify', message: __('certs.added', ['host' => $data['host']]));
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ class Index extends Component
|
||||||
$this->ready = true;
|
$this->ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addCheck(): void
|
public function addCheck(HealthService $health): void
|
||||||
{
|
{
|
||||||
$this->gate();
|
$this->gate();
|
||||||
|
|
||||||
|
|
@ -98,7 +98,7 @@ class Index extends Component
|
||||||
|
|
||||||
$data = $this->validate($rules);
|
$data = $this->validate($rules);
|
||||||
|
|
||||||
HealthCheck::create([
|
$check = HealthCheck::create([
|
||||||
'label' => $data['label'] ?? null,
|
'label' => $data['label'] ?? null,
|
||||||
'type' => $data['type'],
|
'type' => $data['type'],
|
||||||
'target' => $data['target'],
|
'target' => $data['target'],
|
||||||
|
|
@ -106,6 +106,15 @@ class Index extends Component
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->audit('health.check_add', $data['target']);
|
$this->audit('health.check_add', $data['target']);
|
||||||
|
|
||||||
|
// Probe the new check right away so its status/latency shows immediately (not only after a
|
||||||
|
// manual refresh / the next scheduled scan).
|
||||||
|
try {
|
||||||
|
$this->results[$check->uuid] = $health->probe($check);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
$this->reset('label', 'target', 'port');
|
$this->reset('label', 'target', 'port');
|
||||||
$this->type = 'http';
|
$this->type = 'http';
|
||||||
$this->dispatch('notify', message: __('health.added'));
|
$this->dispatch('notify', message: __('health.added'));
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,38 @@ class Email extends Component
|
||||||
$this->dispatch('notify', message: __('mail.notify_saved'));
|
$this->dispatch('notify', message: __('mail.notify_saved'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully clear the SMTP configuration — removes every mail_* setting (incl. the encrypted
|
||||||
|
* password) so the panel reverts to the safe default (log) mailer. Reversible: just reconfigure.
|
||||||
|
*/
|
||||||
|
public function clearConfig(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
|
|
||||||
|
foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) {
|
||||||
|
Setting::forget($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mail_host = '';
|
||||||
|
$this->mail_port = 587;
|
||||||
|
$this->mail_username = '';
|
||||||
|
$this->mail_password = '';
|
||||||
|
$this->mail_encryption = 'tls';
|
||||||
|
$this->mail_from_address = '';
|
||||||
|
$this->mail_from_name = '';
|
||||||
|
$this->passwordSet = false;
|
||||||
|
|
||||||
|
AuditEvent::create([
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'actor' => Auth::user()->name,
|
||||||
|
'action' => 'mail.reset',
|
||||||
|
'target' => '—',
|
||||||
|
'ip' => request()->ip(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->dispatch('notify', message: __('mail.notify_reset'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a one-line test mail to the CURRENT user's own address, using a mailer built
|
* Send a one-line test mail to the CURRENT user's own address, using a mailer built
|
||||||
* from the saved settings. Never targets anyone else; never leaks the password — any
|
* from the saved settings. Never targets anyone else; never leaks the password — any
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,11 @@ class Setting extends Model
|
||||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||||
Cache::forget("setting:{$key}");
|
Cache::forget("setting:{$key}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a setting entirely (so get() falls back to its default). */
|
||||||
|
public static function forget(string $key): void
|
||||||
|
{
|
||||||
|
static::query()->whereKey($key)->delete();
|
||||||
|
Cache::forget("setting:{$key}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Illuminate\Support\Facades\Crypt;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Facades\Vite;
|
use Illuminate\Support\Facades\Vite;
|
||||||
|
|
@ -89,6 +90,13 @@ class AppServiceProvider extends ServiceProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applyMailSettings();
|
$this->applyMailSettings();
|
||||||
|
|
||||||
|
// The queue worker is a LONG-LIVED process: boot() runs once at startup, so a mail-config
|
||||||
|
// change (or SMTP first configured after the worker started — the normal case) would never
|
||||||
|
// reach queued mail, and a fired alert's e-mail would silently use the stale/log mailer.
|
||||||
|
// Re-apply the DB SMTP settings before every job so queued alert/reset mail always sends
|
||||||
|
// with the current config.
|
||||||
|
Queue::before(fn () => $this->applyMailSettings());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ return [
|
||||||
// Aktionen
|
// Aktionen
|
||||||
'save' => 'Speichern',
|
'save' => 'Speichern',
|
||||||
'test_send' => 'Testmail senden',
|
'test_send' => 'Testmail senden',
|
||||||
|
'reset' => 'Zurücksetzen',
|
||||||
|
|
||||||
// Testmail-Inhalt
|
// Testmail-Inhalt
|
||||||
'test_subject' => 'Clusev — Testmail',
|
'test_subject' => 'Clusev — Testmail',
|
||||||
|
|
@ -33,6 +34,7 @@ return [
|
||||||
|
|
||||||
// Benachrichtigungen
|
// Benachrichtigungen
|
||||||
'notify_saved' => 'SMTP-Einstellungen gespeichert.',
|
'notify_saved' => 'SMTP-Einstellungen gespeichert.',
|
||||||
|
'notify_reset' => 'SMTP-Konfiguration zurückgesetzt.',
|
||||||
'notify_test_ok' => 'Testmail an :email gesendet.',
|
'notify_test_ok' => 'Testmail an :email gesendet.',
|
||||||
'notify_test_failed' => 'Testmail fehlgeschlagen: :error',
|
'notify_test_failed' => 'Testmail fehlgeschlagen: :error',
|
||||||
'notify_test_throttled' => 'Zu viele Testmails – bitte in :seconds Sekunden erneut.',
|
'notify_test_throttled' => 'Zu viele Testmails – bitte in :seconds Sekunden erneut.',
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ return [
|
||||||
// Actions
|
// Actions
|
||||||
'save' => 'Save',
|
'save' => 'Save',
|
||||||
'test_send' => 'Send test mail',
|
'test_send' => 'Send test mail',
|
||||||
|
'reset' => 'Reset',
|
||||||
|
|
||||||
// Test mail content
|
// Test mail content
|
||||||
'test_subject' => 'Clusev — test mail',
|
'test_subject' => 'Clusev — test mail',
|
||||||
|
|
@ -33,6 +34,7 @@ return [
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notify_saved' => 'SMTP settings saved.',
|
'notify_saved' => 'SMTP settings saved.',
|
||||||
|
'notify_reset' => 'SMTP configuration reset.',
|
||||||
'notify_test_ok' => 'Test mail sent to :email.',
|
'notify_test_ok' => 'Test mail sent to :email.',
|
||||||
'notify_test_failed' => 'Test mail failed: :error',
|
'notify_test_failed' => 'Test mail failed: :error',
|
||||||
'notify_test_throttled' => 'Too many test e-mails — try again in :seconds seconds.',
|
'notify_test_throttled' => 'Too many test e-mails — try again in :seconds seconds.',
|
||||||
|
|
|
||||||
|
|
@ -527,7 +527,10 @@ document.addEventListener('alpine:init', () => {
|
||||||
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
|
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
|
||||||
// `h` (Help) is intentionally absent — it resolves via the config-driven nav list in go()
|
// `h` (Help) is intentionally absent — it resolves via the config-driven nav list in go()
|
||||||
// so it can point at the external docs URL instead of an in-app route.
|
// so it can point at the external docs URL instead of an in-app route.
|
||||||
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', t: '/terminal' };
|
// NOTE: 't' (Terminal) is deliberately NOT here — /terminal is admin-only (manage-fleet), so it
|
||||||
|
// is a ROLE-FILTERED entry in the server-rendered nav list instead; go() resolves it there only
|
||||||
|
// when the user actually has it, so lower roles never dead-end on a 403.
|
||||||
|
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' };
|
||||||
|
|
||||||
window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({
|
window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({
|
||||||
nav,
|
nav,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@
|
||||||
['label' => __('shell.nav_help'), 'href' => config('clusev.docs_url'), 'hint' => 'g h'],
|
['label' => __('shell.nav_help'), 'href' => config('clusev.docs_url'), 'hint' => 'g h'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Terminal is admin-only (manage-fleet) — add its "g t" chord + palette entry ONLY for admins,
|
||||||
|
// so lower roles neither see it nor dead-end on the now-gated /terminal route.
|
||||||
|
if (auth()->user()?->can('manage-fleet')) {
|
||||||
|
$chords[] = ['g t', __('shell.nav_terminal')];
|
||||||
|
$nav[] = ['label' => __('shell.nav_terminal'), 'href' => '/terminal', 'hint' => 'g t'];
|
||||||
|
}
|
||||||
|
|
||||||
$actions = [
|
$actions = [
|
||||||
['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''],
|
['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
'pending' => 'text-cyan border-cyan/20 bg-cyan/10',
|
'pending' => 'text-cyan border-cyan/20 bg-cyan/10',
|
||||||
][$status] ?? 'text-ink-3 border-line bg-line';
|
][$status] ?? 'text-ink-3 border-line bg-line';
|
||||||
@endphp
|
@endphp
|
||||||
<span {{ $attributes->merge(['class' => "inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-xs $c"]) }}>
|
{{-- h-8 = the small-button height, so a status pill and the action buttons in the same row line up. --}}
|
||||||
|
<span {{ $attributes->merge(['class' => "inline-flex h-8 items-center gap-1.5 rounded-sm border px-2.5 font-mono text-xs $c"]) }}>
|
||||||
<x-status-dot :status="$status" :ping="in_array($status, ['online', 'pending'], true)" />
|
<x-status-dot :status="$status" :ping="in_array($status, ['online', 'pending'], true)" />
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span>
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-online/70"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-online/70"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate font-mono text-[12px] text-ink-2" x-text="title || '{{ $server->name }}'"></span>
|
{{-- Server name via a data-attr, NEVER interpolated into the Alpine expression: Blade
|
||||||
|
escaping does not protect an x-text JS sink (the browser entity-decodes before
|
||||||
|
Alpine evals), so a name like x'+alert(1)+' would execute. dataset = plain string. --}}
|
||||||
|
<span class="truncate font-mono text-[12px] text-ink-2" data-fallback="{{ $server->name }}" x-text="title || $el.dataset.fallback"></span>
|
||||||
<span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]">
|
<span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]">
|
||||||
<span class="h-1.5 w-1.5 rounded-full"
|
<span class="h-1.5 w-1.5 rounded-full"
|
||||||
:class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span>
|
:class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span>
|
||||||
|
|
|
||||||
|
|
@ -60,16 +60,25 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-end gap-2 border-t border-line pt-4">
|
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-line pt-4">
|
||||||
<x-btn variant="secondary" type="button" wire:click="sendTest" wire:target="sendTest" wire:loading.attr="disabled"
|
<div>
|
||||||
:disabled="! $this->configured()">
|
@if ($this->configured())
|
||||||
<svg wire:loading wire:target="sendTest" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
<x-btn variant="danger-soft" type="button" wire:click="clearConfig" wire:target="clearConfig" wire:loading.attr="disabled">
|
||||||
{{ __('mail.test_send') }}
|
{{ __('mail.reset') }}
|
||||||
</x-btn>
|
</x-btn>
|
||||||
<x-btn variant="primary" type="submit" wire:target="save" wire:loading.attr="disabled">
|
@endif
|
||||||
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
</div>
|
||||||
{{ __('common.save') }}
|
<div class="flex flex-wrap gap-2">
|
||||||
</x-btn>
|
<x-btn variant="secondary" type="button" wire:click="sendTest" wire:target="sendTest" wire:loading.attr="disabled"
|
||||||
|
:disabled="! $this->configured()">
|
||||||
|
<svg wire:loading wire:target="sendTest" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||||
|
{{ __('mail.test_send') }}
|
||||||
|
</x-btn>
|
||||||
|
<x-btn variant="primary" type="submit" wire:target="save" wire:loading.attr="disabled">
|
||||||
|
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||||
|
{{ __('common.save') }}
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span>
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-online/70"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-online/70"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate font-mono text-[12px] text-ink-2" x-text="title || '{{ __('terminal.host_label') }}'"></span>
|
<span class="truncate font-mono text-[12px] text-ink-2" data-fallback="{{ __('terminal.host_label') }}" x-text="title || $el.dataset.fallback"></span>
|
||||||
<span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]">
|
<span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]">
|
||||||
<span class="h-1.5 w-1.5 rounded-full"
|
<span class="h-1.5 w-1.5 rounded-full"
|
||||||
:class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span>
|
:class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,31 @@ class SmtpConfigTest extends TestCase
|
||||||
$this->assertDatabaseHas('audit_events', ['action' => 'mail.configure', 'target' => 'smtp.example.com']);
|
$this->assertDatabaseHas('audit_events', ['action' => 'mail.configure', 'target' => 'smtp.example.com']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_clear_config_removes_all_mail_settings(): void
|
||||||
|
{
|
||||||
|
$this->actingUser();
|
||||||
|
Setting::put('mail_host', 'smtp.example.com');
|
||||||
|
Setting::put('mail_port', '2525');
|
||||||
|
Setting::put('mail_username', 'panel');
|
||||||
|
Setting::put('mail_password', Crypt::encryptString('s3cr3t-pw'));
|
||||||
|
Setting::put('mail_encryption', 'ssl');
|
||||||
|
Setting::put('mail_from_address', 'panel@example.com');
|
||||||
|
Setting::put('mail_from_name', 'Clusev');
|
||||||
|
|
||||||
|
Livewire::test(Email::class)
|
||||||
|
->assertSet('mail_host', 'smtp.example.com') // mounted from the stored value
|
||||||
|
->call('clearConfig')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('mail_host', '')
|
||||||
|
->assertSet('mail_from_address', '')
|
||||||
|
->assertSet('passwordSet', false);
|
||||||
|
|
||||||
|
foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) {
|
||||||
|
$this->assertNull(Setting::get($key), "$key must be cleared");
|
||||||
|
}
|
||||||
|
$this->assertDatabaseHas('audit_events', ['action' => 'mail.reset']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_password_is_not_overwritten_when_left_blank(): void
|
public function test_password_is_not_overwritten_when_left_blank(): void
|
||||||
{
|
{
|
||||||
$this->actingUser();
|
$this->actingUser();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue