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 = Operator::factory()->role('Read-only')->create(['password' => 'password']); Livewire::actingAs($user, 'operator')->test(MailPage::class)->assertForbidden(); }); it('opens for an operator who has it', function () { $user = Operator::factory()->role('Owner')->create(['password' => 'password']); Livewire::actingAs($user, 'operator')->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(Operator::factory()->role('Admin')->create(['password' => 'password'])->can('mail.manage'))->toBeTrue() ->and(Operator::factory()->role('Support')->create(['password' => 'password'])->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 = Operator::factory()->role('Billing')->create(['password' => 'password']); expect($user->can('mail.manage'))->toBeFalse(); Livewire::actingAs($user, 'operator')->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(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->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(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->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(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->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(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->assertOk() ->assertSet('usable', false) ->assertSee(__('mail_settings.no_key')); }); it('shows the same SECRETS_KEY warning for a malformed key, not only a missing one', function () { // Codex R15#3, P2: Mail::render() sets $this->usable from the very same // SecretCipher::isUsable() EditMailbox and MailboxTester gate on. Before // the fix, a malformed (nonempty, wrong-length) key answered true here, // so the page claimed mailbox passwords could be stored when they could // not — the two tests below are what happened next. config()->set('admin_access.secrets_key', 'zu-kurz'); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->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']); $owner = Operator::factory()->role('Owner')->create(['password' => 'password']); // Confirmed BEFORE the key is cleared: this test is about the SECRETS_KEY // guard specifically, not about the password-confirmation gate above it — // conflating the two would leave it unclear which one actually produced // the error. $page = Livewire::actingAs($owner, 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('confirmablePassword', 'password') ->call('confirmPassword'); config()->set('admin_access.secrets_key', ''); $page->set('password', 'neu-und-sicher') ->call('save') ->assertHasErrors('password'); }); it('fails the password save with a form error, not an exception, for a malformed SECRETS_KEY too', function () { // Codex R15#3, P2: the guard this mirrors (EditMailbox::save(), a few // lines up) is a pre-check on SecretCipher::isUsable() specifically to // stop an uncaught RuntimeException from Mailbox::setPasswordAttribute() // reaching the operator as Laravel's debug page — whose request-payload // inspector can show the very password just typed. Before the fix, that // guard did not fire here: isUsable() answered true for a malformed key, // and $box->password = $this->password went ahead and threw uncaught. $box = Mailbox::factory()->create(['key' => 'support']); $owner = Operator::factory()->role('Owner')->create(['password' => 'password']); $page = Livewire::actingAs($owner, 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('confirmablePassword', 'password') ->call('confirmPassword'); config()->set('admin_access.secrets_key', 'zu-kurz'); $page->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(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.system', '') ->call('savePurposes') ->assertHasErrors('purposes.system'); expect(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('no-reply'); }); it('refuses to deactivate the mailbox that "system" currently points at', function () { // savePurposes() above refuses to leave system EMPTY. Nothing stopped an // operator reaching the identical broken state — every purpose without // its own mailbox unable to send — by unchecking Aktiv on the mailbox // system already points at instead: MailboxResolver::active() filters an // inactive mailbox at both the direct lookup and the system fallback, so // resolution() returns null everywhere at once. $box = Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('active', false) ->call('save') ->assertHasErrors('active'); expect($box->fresh()->active)->toBeTrue(); }); it('still allows deactivating a mailbox that is not behind "system"', function () { // The control case for the guard above: the fix must refuse THIS ONE // transition, not deactivation in general — "saves display name, // username, no-reply and active together" already proves the field // itself still works, but not against a system mapping that exists and // simply points elsewhere. $systemBox = Mailbox::factory()->create(['key' => 'no-reply']); $otherBox = Mailbox::factory()->create(['key' => 'support', 'active' => true]); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $otherBox->uuid]) ->set('active', false) ->call('save') ->assertHasNoErrors(); expect($otherBox->fresh()->active)->toBeFalse() ->and($systemBox->fresh()->active)->toBeTrue(); }); // --- 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(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->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(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->assertSet('purposes.support', 'support'); }); it('saves the mail server settings', function () { Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->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(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->set('host', '') ->call('saveServer') ->assertHasErrors('host'); }); // --- Codex R15#6, P2: last_verified_at proves a mailbox was tested against a // SPECIFIC server config. Changing host, port or encryption here silently // invalidates that proof for every mailbox at once — the shared-server mirror // of EditMailbox::save()'s guard on a single mailbox's own address/username/ // authenticates. Each test below pins host/port/encryption to a KNOWN old // value via Settings::set() before mounting, rather than trusting whatever // RefreshDatabase's baseline seed happens to contain — the point is to control // exactly which one field moves. it("clears every mailbox's last_verified_at when the server host changes", function () { Settings::set('mail.host', 'old-relay.example.com'); Settings::set('mail.port', 587); Settings::set('mail.encryption', 'tls'); $a = Mailbox::factory()->create(['last_verified_at' => now()]); $b = Mailbox::factory()->create(['last_verified_at' => now()]); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->set('host', 'new-relay.example.com') ->call('saveServer') ->assertHasNoErrors(); expect($a->fresh()->last_verified_at)->toBeNull() ->and($b->fresh()->last_verified_at)->toBeNull(); }); it("clears every mailbox's last_verified_at when the server port changes", function () { Settings::set('mail.host', 'relay.example.com'); Settings::set('mail.port', 587); Settings::set('mail.encryption', 'tls'); $box = Mailbox::factory()->create(['last_verified_at' => now()]); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->set('port', 2525) ->call('saveServer') ->assertHasNoErrors(); expect($box->fresh()->last_verified_at)->toBeNull(); }); it("clears every mailbox's last_verified_at when the server encryption changes", function () { Settings::set('mail.host', 'relay.example.com'); Settings::set('mail.port', 587); Settings::set('mail.encryption', 'tls'); $box = Mailbox::factory()->create(['last_verified_at' => now()]); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->set('encryption', 'ssl') ->call('saveServer') ->assertHasNoErrors(); expect($box->fresh()->last_verified_at)->toBeNull(); }); it('keeps last_verified_at when the server card is saved with no actual change', function () { // The control case for the three tests above: an operator who opens the // card and saves without editing anything has changed nothing, and must // not wipe a legitimate verification. Sets every field to the SAME value // mount() already read, rather than never calling set() at all — this is // what "re-saving identical values" actually looks like through the form. Settings::set('mail.host', 'relay.example.com'); Settings::set('mail.port', 587); Settings::set('mail.encryption', 'tls'); $box = Mailbox::factory()->create(['last_verified_at' => now()]); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->set('host', 'relay.example.com') ->set('port', 587) ->set('encryption', 'tls') ->call('saveServer') ->assertHasNoErrors(); expect($box->fresh()->last_verified_at)->not->toBeNull(); }); it('shows "not yet verified" instead of a blank cell once the server change clears it', function () { // The console-list-facing half of the finding: a cleared timestamp must // read as an honest, explicit "not yet verified", not a blank cell that // could be mistaken for a rendering error. Settings::set('mail.host', 'relay.example.com'); Settings::set('mail.port', 587); Settings::set('mail.encryption', 'tls'); Mailbox::factory()->create(['key' => 'support', 'last_verified_at' => now()]); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->set('host', 'new-relay.example.com') ->call('saveServer') ->assertSee(__('mail_settings.never_verified')); }); it('refuses to change the mail server without a recently confirmed password, capability notwithstanding', function () { // saveServer() writes mail.host — the platform's outbound relay for every // purpose mailbox at once. An Owner has mail.manage for the whole test; // nothing here proves the SECOND gate exists unless a properly capable // session still gets blocked for want of a recent confirmation. $before = Settings::get('mail.host'); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('host', 'evil-relay.example.com') ->set('port', 2525) ->set('encryption', 'ssl') ->call('saveServer') ->assertForbidden(); // Not ->toBeNull(): RefreshDatabase's baseline migrate already seeds // mail.host from the real environment (see the seed migration), so a // blank slate is not what "the write was blocked" looks like here — an // unchanged value, still not the attacker's relay, is. expect(Settings::get('mail.host'))->toBe($before) ->and(Settings::get('mail.host'))->not->toBe('evil-relay.example.com'); }); it('does not require a confirmed password to save the purpose mapping', function () { // The mirror of the test above: saveServer() is gated, savePurposes() is // deliberately not — this is the "changing an address" split the brief // describes, proven from the ungated side rather than left implicit in // the tests that merely never happen to call confirmPassword(). Mailbox::factory()->create(['key' => 'support']); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.system', 'support') ->set('purposes.support', 'support') ->call('savePurposes') ->assertHasNoErrors(); expect(Settings::get(MailPurpose::settingKey(MailPurpose::SUPPORT)))->toBe('support'); }); it('saves the purpose-to-mailbox mapping', function () { Mailbox::factory()->create(['key' => 'support']); Mailbox::factory()->create(['key' => 'no-reply']); Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->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'); }); // --- P2: a Livewire request can post any string to purposes.*, including one // posted straight to /livewire/update that never went through the