Skip to content

Commit e6100e1

Browse files
committed
test: assert the passwordless rejection is indistinguishable, not just unworded
testInvalidOtpAgainstEnforcedUserDoesNotLeakMFAStatus only asserted that the flash message lacked the phrase "two-factor authentication". Enumeration is about distinguishability, so that assertion still passed if a future enforced-only branch leaked a differently worded message. It now drives the same invalid code through the same endpoint for the MFA-enforced admin and for a non-enforced control user, and asserts the two rejections are byte-identical. The stale flash is cleared between requests so the comparison cannot read a value against itself. Verified by mutation: restoring the pre-fix guard order with reworded text fails the new assertSame and would have passed the old substring assertion. Also records why testEnforcedUserCannotBypassMFAViaPasswordlessLogin adds no separate "no login side effect" assertions. Both candidates were tried and removed as change detectors that cannot fail: the DB-visible effects (OTP redemption, sibling revocation) are rolled back by the AuthService transaction regardless of where the guard sits, and the session-visible one (Auth::login() and the Login event queuing PostLoginUser) is already caught first by the existing Auth::check() assertion. Both confirmed by mutation. Full file green: OK (58 tests, 380 assertions).
1 parent 67043e3 commit e6100e1

1 file changed

Lines changed: 40 additions & 3 deletions

File tree

tests/TwoFactorLoginFlowTest.php

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ public function testNonAdminWithoutMFALogsInNormally(): void
193193

194194
public function testEnforcedUserCannotBypassMFAViaPasswordlessLogin(): void
195195
{
196+
// No separate "no login side effect" assertions here on purpose: the
197+
// DB-visible ones (OTP redemption, sibling revocation) are unreachable
198+
// by construction, because the guard throws inside the AuthService
199+
// transaction and DoctrineTransactionService rolls the whole closure
200+
// back - moving the guard below finalizeRedemption() leaves the OTP
201+
// un-redeemed all the same. The session-visible one (Auth::login() and
202+
// the Login event that queues PostLoginUser) is already covered by the
203+
// Auth::check() assertion below, which is what fails first if the guard
204+
// is moved below Auth::login(). Both were verified by mutation.
196205
$this->emitOTP(self::ADMIN_EMAIL);
197206
$code = $this->latestOtpCode(self::ADMIN_EMAIL);
198207

@@ -222,15 +231,43 @@ public function testInvalidOtpAgainstEnforcedUserDoesNotLeakMFAStatus(): void
222231
// the inbox - an account-enumeration oracle. The check must now only be
223232
// reachable after loginWithOTPEnforcing2FA() has proven the code valid,
224233
// so an invalid code gets the same generic rejection for any account.
225-
$this->emitOTP(self::ADMIN_EMAIL);
234+
//
235+
// Enumeration is about DISTINGUISHABILITY, not about one phrase: merely
236+
// asserting the absence of "two-factor authentication" would still pass
237+
// if some future enforced-only branch leaked a *differently* worded
238+
// message. So the enforced account's rejection is compared byte-for-byte
239+
// against a non-enforced control driven through the same endpoint with
240+
// the same bad code.
241+
$control_email = $this->createPlainUser();
226242

227-
$response = $this->postLoginOTP(self::ADMIN_EMAIL, 'not-the-real-code');
243+
$this->emitOTP(self::ADMIN_EMAIL);
244+
$this->postLoginOTP(self::ADMIN_EMAIL, 'not-the-real-code');
228245

229246
$this->assertFalse(Auth::check(), 'an invalid code must never authenticate');
230247
$this->assertResponseStatus(302);
248+
$enforced_notice = Session::get('flash_notice');
249+
$this->assertNotNull($enforced_notice, 'the enforced account must get a flashed rejection');
250+
251+
// Cleared so the control's assertion cannot silently read the enforced
252+
// account's leftover flash and compare a value against itself.
253+
Session::forget('flash_notice');
254+
255+
$this->emitOTP($control_email);
256+
$this->postLoginOTP($control_email, 'not-the-real-code');
257+
258+
$this->assertFalse(Auth::check(), 'an invalid code must never authenticate the control account either');
259+
$this->assertResponseStatus(302);
260+
$control_notice = Session::get('flash_notice');
261+
$this->assertNotNull($control_notice, 'the control account must get a flashed rejection');
262+
263+
$this->assertSame(
264+
$control_notice,
265+
$enforced_notice,
266+
'an invalid code must produce an identical rejection for an enforced and a non-enforced account - any difference is an enumeration oracle'
267+
);
231268
$this->assertStringNotContainsString(
232269
'two-factor authentication',
233-
Session::get('flash_notice'),
270+
$enforced_notice,
234271
'an invalid code must not leak that the account is MFA-enforced'
235272
);
236273
}

0 commit comments

Comments
 (0)