set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); // Blank slate, not the migration's seeded five: every other file in this // directory does the same (see tests/Pest.php). Two of the tests below // pin a mailbox to the key 'support' and another to 'no-reply' — both // collide with mailboxes.key's unique constraint against the seeded // baseline RefreshDatabase leaves in place. Clearing keeps this file // deterministic regardless of what the seed migration happens to contain. clearMailboxSeed(); }); it('is closed to an operator without mail.manage', function () { $user = User::factory()->operator('Read-only')->create(); Livewire::actingAs($user)->test(MailPage::class)->assertForbidden(); }); it('opens for an operator who has it', function () { $user = User::factory()->operator('Owner')->create(); Livewire::actingAs($user)->test(MailPage::class)->assertOk()->assertSet('usable', true); }); it('grants mail.manage to Admin and Support as well as Owner', function () { // The migration hands the capability to three roles, not one. Nothing // above proves the other two actually received it — an Owner-only test // would stay green even if the migration's role loop silently lost a name. expect(User::factory()->operator('Admin')->create()->can('mail.manage'))->toBeTrue() ->and(User::factory()->operator('Support')->create()->can('mail.manage'))->toBeTrue(); }); it('is not opened by secrets.manage alone — that is the point of splitting them', function () { // Billing holds secrets-adjacent capabilities but has no business changing // the support sender, and vice versa. If this ever passes, the two // capabilities have quietly been merged again. $user = User::factory()->operator('Billing')->create(); expect($user->can('mail.manage'))->toBeFalse(); Livewire::actingAs($user)->test(MailPage::class)->assertForbidden(); }); it('edits a mailbox in a modal, never in the row (R20)', function () { $box = Mailbox::factory()->create(['key' => 'support']); $blade = file_get_contents(resource_path('views/livewire/admin/mail.blade.php')); expect($blade)->not->toMatch('/]*>(?:(?!<\/td>).)*and($blade)->toContain('openModal'); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('address', 'neu@clupilot.com') ->call('save'); expect($box->fresh()->address)->toBe('neu@clupilot.com'); }); it('never hands a stored password back to the browser', function () { $box = Mailbox::factory()->create(['password' => 'streng-geheim']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->assertSet('password', '') ->assertDontSee('streng-geheim'); }); it('keeps the old password when the field is left empty', function () { $box = Mailbox::factory()->create(['password' => 'bleibt']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('address', 'neu@clupilot.com') ->call('save'); expect($box->fresh()->password)->toBe('bleibt'); }); it('shows the SECRETS_KEY warning on the page', function () { config()->set('admin_access.secrets_key', ''); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->assertOk() ->assertSet('usable', false) ->assertSee(__('mail_settings.no_key')); }); it('fails the password save with a form error, not an exception, when SECRETS_KEY is missing', function () { // The test above only proves the PAGE says so. Nothing proved that typing // a password into the MODAL and saving it fails gracefully — the gap a // reviewer found: left unfixed, SecretCipher::encrypt() throws all the way // up, and with APP_DEBUG on, Laravel's debug page can put the just-typed // plaintext password in its request-payload inspector. All three // password-writing tests above run under this file's valid-key beforeEach, // so none of them would have caught it. $box = Mailbox::factory()->create(['key' => 'support']); config()->set('admin_access.secrets_key', ''); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('password', 'neu-und-sicher') ->call('save') ->assertHasErrors('password'); }); it('refuses to leave the system purpose empty, because it is the fallback', function () { Mailbox::factory()->create(['key' => 'no-reply']); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('purposes.system', '') ->call('savePurposes') ->assertHasErrors('purposes.system'); expect(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('no-reply'); }); // --- Coverage beyond the brief's list: behaviours the page and modal claim // but the tests above never exercise. Each was verified to fail for the // right reason before the implementation existed, and again by mutation // afterwards (see the task report). it('lists every mailbox on the page', function () { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->assertSee('support@clupilot.com') ->assertSee('support'); }); it('shows the currently configured mailbox for each purpose', function () { Mailbox::factory()->create(['key' => 'support']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->assertSet('purposes.support', 'support'); }); it('saves the mail server settings', function () { Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('host', 'smtp.example.com') ->set('port', 2525) ->set('encryption', 'ssl') ->call('saveServer') ->assertHasNoErrors(); expect(Settings::get('mail.host'))->toBe('smtp.example.com') ->and(Settings::get('mail.port'))->toBe(2525) ->and(Settings::get('mail.encryption'))->toBe('ssl'); }); it('rejects an empty mail server host', function () { Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('host', '') ->call('saveServer') ->assertHasErrors('host'); }); it('saves the purpose-to-mailbox mapping', function () { Mailbox::factory()->create(['key' => 'support']); Mailbox::factory()->create(['key' => 'no-reply']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('purposes.system', 'no-reply') ->set('purposes.support', 'support') ->call('savePurposes') ->assertHasNoErrors(); expect(Settings::get(MailPurpose::settingKey(MailPurpose::SUPPORT)))->toBe('support') ->and(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('no-reply'); }); it('re-checks mail.manage on every page action, not only at mount', function () { // mount() alone would pass every test above: an Owner has the capability // for the whole test, so nothing so far proves saveServer()/savePurposes() // carry their own authorize() rather than relying on mount() having run // once. Swap the authenticated user between mount and the action — same // shape as SecretsPageTest's "refuses every action while locked", applied // to the capability gate instead of the password gate. $owner = User::factory()->operator('Owner')->create(); $billing = User::factory()->operator('Billing')->create(); // A fresh mount per action, like SecretsPageTest's equivalent: a 403 // response carries no usable Livewire snapshot, so a second ->call() on // the same already-forbidden instance fails on the snapshot itself // rather than on the authorization this test means to check. foreach (['saveServer', 'savePurposes'] as $action) { $page = Livewire::actingAs($owner)->test(MailPage::class)->assertOk(); Livewire::actingAs($billing); $page->call($action)->assertForbidden(); } }); it('re-checks mail.manage on modal save, not only at mount', function () { // Same gap as above, for the modal: mount() re-reads the record instead of // trusting the browser (R20), but that is a different guarantee from // save() re-authorising instead of trusting a mount that already happened. $box = Mailbox::factory()->create(['key' => 'support']); $owner = User::factory()->operator('Owner')->create(); $billing = User::factory()->operator('Billing')->create(); $modal = Livewire::actingAs($owner)->test(EditMailbox::class, ['uuid' => $box->uuid])->assertOk(); Livewire::actingAs($billing); $modal->set('address', 'neu@clupilot.com')->call('save')->assertForbidden(); }); it('refuses to edit a mailbox without mail.manage, even though the modal bypasses the page route', function () { $box = Mailbox::factory()->create(['key' => 'support']); Livewire::actingAs(User::factory()->operator('Billing')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->assertForbidden(); }); it('updates the password when a new one is entered', function () { // The mirror of "keeps the old password when the field is left empty": // that test alone would stay green even if save() stopped writing the // password entirely, since "never changes" also satisfies "kept the old // one". This proves the other half — a new value actually lands. $box = Mailbox::factory()->create(['password' => 'alt']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('password', 'neu-und-sicher') ->call('save'); expect($box->fresh()->password)->toBe('neu-und-sicher'); }); it('clears last_verified_at when the password changes, since it no longer proves anything', function () { $box = Mailbox::factory()->create(['password' => 'alt', 'last_verified_at' => now()]); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('password', 'neu-und-sicher') ->call('save'); expect($box->fresh()->last_verified_at)->toBeNull(); }); it('saves display name, username, no-reply and active together', function () { $box = Mailbox::factory()->create(['no_reply' => false, 'active' => true]); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('displayName', 'Support-Team') ->set('username', 'smtp-user') ->set('noReply', true) ->set('active', false) ->call('save'); $box->refresh(); expect($box->display_name)->toBe('Support-Team') ->and($box->username)->toBe('smtp-user') ->and($box->no_reply)->toBeTrue() ->and($box->active)->toBeFalse(); }); it('pre-fills the form from the existing record', function () { $box = Mailbox::factory()->create([ 'address' => 'support@clupilot.com', 'display_name' => 'Support-Team', 'username' => 'smtp-support', 'no_reply' => true, 'active' => false, ]); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->assertSet('address', 'support@clupilot.com') ->assertSet('displayName', 'Support-Team') ->assertSet('username', 'smtp-support') ->assertSet('noReply', true) ->assertSet('active', false); }); // --- Task 8: the test-send button. MailboxTesterTest.php covers // MailboxTester itself in depth (bypassing the log/array mailer, verbatim // error passthrough, the SECRETS_KEY guard, a real success and a real SMTP // rejection). What is left to prove here is the WIRING: does the button on // this page reach that service with the right arguments, under the right // authorization and validation, and does the page show what came back. it('refuses to run a mailbox test without mail.manage, re-checked independently of mount', function () { $box = Mailbox::factory()->create(['key' => 'support']); $owner = User::factory()->operator('Owner')->create(); $billing = User::factory()->operator('Billing')->create(); $page = Livewire::actingAs($owner)->test(MailPage::class)->assertOk(); Livewire::actingAs($billing); $page->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) ->assertForbidden(); }); it('requires a recipient before sending a test', function () { $box = Mailbox::factory()->create(['key' => 'support']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('testRecipient', '') ->call('test', $box->uuid) ->assertHasErrors('testRecipient'); }); it('requires the recipient to actually be an email address', function () { $box = Mailbox::factory()->create(['key' => 'support']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('testRecipient', 'not-an-email') ->call('test', $box->uuid) ->assertHasErrors('testRecipient'); }); it('runs the real tester against the chosen mailbox and records the result', function () { // 127.0.0.1:1 is refused in well under a millisecond (see // MailboxTesterTest) — fast and deterministic enough to run through the // REAL MailboxTester here rather than a mock, proving the button is // wired to the thing that actually sends, not a stand-in for it. Settings::set('mail.host', '127.0.0.1'); Settings::set('mail.port', 1); $box = Mailbox::factory()->create(['key' => 'support']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) ->assertSet('testedKey', 'support') ->assertSet('testResult.ok', false) ->assertSet('testResult.error', fn ($error) => str_contains((string) $error, 'Connection refused')); }); it('shows the result against the mailbox it actually tested', function () { // Not assertSee('support') alone: the mailboxes table already prints // every box's own key in its row regardless of which one was tested, so // that assertion would stay green even if $testedKey were wired to the // wrong box (caught by mutation — see the task report). 'support:' with // the colon is unique to the alert's ": " markup; the table // row never follows a key with one. Settings::set('mail.host', '127.0.0.1'); Settings::set('mail.port', 1); $box = Mailbox::factory()->create(['key' => 'support']); Mailbox::factory()->create(['key' => 'billing']); // a second row: proves it's not just "any key" Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) ->assertSee('support:') ->assertDontSee('billing:') ->assertSee(__('mail_settings.test_failed')); }); it('does not crash the test button when SECRETS_KEY is missing, and says so instead', function () { // The gap the brief could not know about: $box->password decrypts on // read, and this installation has no SECRETS_KEY at all. Proven at the // MailboxTester unit level already — this proves the BUTTON reaches that // guard rather than a mock standing in for it, the same distinction // "re-checks mail.manage on every page action" draws for authorization. $box = Mailbox::factory()->create(['key' => 'support']); config()->set('admin_access.secrets_key', ''); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) ->assertSet('testResult.error', __('mail_settings.no_key')); }); it('offers a per-row test-send button, not only edit', function () { $blade = file_get_contents(resource_path('views/livewire/admin/mail.blade.php')); expect($blade)->toContain('wire:click="test('); }); it('tells the operator the test send really goes out, even though ordinary mail only logs here', function () { Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->assertSee(__('mail_settings.test_hint')); });