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