Skip to content

Commit 89e058c

Browse files
committed
test: cover recovery-code redemption, re-enrollment, and the real request layer
Three gaps mapped to the riskiest parts of this PR were unpinned: 1. Nothing proved a code returned as XXXX-XXXX actually redeems through AbstractMFAChallengeStrategy::verifyRecoveryCode() - the hash is of the dash-less string, so the generate->display->redeem contract (including the dash normalization) was untested. 2. enableTwoFactor()'s already-enrolled guard (412) had no regression test. 3. Every JS test mocked profile/actions, so nothing exercised the real request layer - exactly where the missing postRawRequestFull export lived. tests/js/profile/actions.test.js only stubs the transport (superagent) and calls the real enableTwoFactor/regenerateRecoveryCodes; verified it reproduces the original "postRawRequestFull is not a function" TypeError when that export is removed.
1 parent bcd0330 commit 89e058c

2 files changed

Lines changed: 103 additions & 0 deletions

File tree

tests/RecoveryCodeRegenerationTest.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Illuminate\Support\Facades\Hash;
1919
use Illuminate\Support\Facades\Session;
2020
use LaravelDoctrine\ORM\Facades\EntityManager;
21+
use Strategies\MFA\MFAChallengeStrategyFactory;
2122

2223
/**
2324
* Integration tests for regenerating recovery codes from the user profile
@@ -123,6 +124,51 @@ public function testEnableTwoFactorGeneratesRecoveryCodes(): void
123124
$this->assertCount($expectedCount, $remaining);
124125
}
125126

127+
public function testDisplayedRecoveryCodeRedeemsThroughVerifyRecoveryCode(): void
128+
{
129+
$admin = $this->admin();
130+
131+
$response = $this->regenerate(self::SEED_PASSWORD);
132+
$this->assertResponseStatus(200);
133+
$payload = json_decode($response->getContent(), true);
134+
$displayedCode = $payload['recovery_codes'][0];
135+
$this->assertMatchesRegularExpression('/^[A-Z0-9]+-[A-Z0-9]+$/', $displayedCode);
136+
137+
EntityManager::clear();
138+
$admin = EntityManager::getRepository(User::class)->find($admin->getId());
139+
$unusedBefore = EntityManager::getRepository(UserRecoveryCode::class)
140+
->findBy(['user' => $admin->getId(), 'used_at' => null]);
141+
142+
// The hash was generated over the dash-less string; redeeming the code
143+
// exactly as it was displayed (with its "-" separator) must still work.
144+
$strategy = MFAChallengeStrategyFactory::create(User::MFAMethod_OTP);
145+
$strategy->verifyRecoveryCode($admin, $displayedCode);
146+
147+
EntityManager::clear();
148+
$unusedAfter = EntityManager::getRepository(UserRecoveryCode::class)
149+
->findBy(['user' => $admin->getId(), 'used_at' => null]);
150+
151+
$this->assertCount(
152+
count($unusedBefore) - 1,
153+
$unusedAfter,
154+
'the code redeemed exactly as displayed must be consumed exactly once'
155+
);
156+
}
157+
158+
public function testEnableTwoFactorRejectsWhenAlreadyEnabled(): void
159+
{
160+
$this->enableTwoFactor('email_otp');
161+
$this->assertResponseStatus(200);
162+
163+
$response = $this->enableTwoFactor('email_otp');
164+
165+
$this->assertResponseStatus(412);
166+
167+
EntityManager::clear();
168+
$admin = $this->admin();
169+
$this->assertTrue($admin->isTwoFactorEnabled(), '2FA must remain enabled after the rejected second call');
170+
}
171+
126172
public function testEnableTwoFactorRejectsUnavailableMethod(): void
127173
{
128174
// sms_otp is a stub in Phase I (isPhoneNumberVerified() is hardcoded false),

tests/js/profile/actions.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Unlike the RecoveryCodesPanel/TwoFactorSection tests, this suite does NOT
2+
// mock "profile/actions" - it exercises the real enableTwoFactor()/
3+
// regenerateRecoveryCodes() against the real base_actions.js request layer,
4+
// only stubbing the transport (superagent) itself. This is the layer where a
5+
// broken import (e.g. a named export that doesn't exist) actually blows up -
6+
// a test that mocks profile/actions can never catch that.
7+
jest.mock("superagent", () => ({
8+
post: jest.fn(),
9+
}));
10+
11+
import request from "superagent";
12+
import { enableTwoFactor, regenerateRecoveryCodes } from "profile/actions";
13+
14+
function makeChainableRequest({ body = {}, error = null } = {}) {
15+
const req = {};
16+
req.set = jest.fn(() => req);
17+
req.send = jest.fn(() => req);
18+
req.timeout = jest.fn(() => req);
19+
req.then = (onFulfilled, onRejected) =>
20+
(error ? Promise.reject(error) : Promise.resolve({ body })).then(onFulfilled, onRejected);
21+
req.catch = (onRejected) => req.then(undefined, onRejected);
22+
return req;
23+
}
24+
25+
describe("profile/actions (unmocked request layer)", () => {
26+
beforeEach(() => {
27+
request.post.mockReset();
28+
window.ENABLE_TWO_FACTOR_ENDPOINT = "https://idp.test/api/v2/users/me/2fa/enable";
29+
window.REGENERATE_RECOVERY_CODES_ENDPOINT = "https://idp.test/api/v2/users/me/2fa/recovery-codes";
30+
window.CSFR_TOKEN = "test-csrf-token";
31+
});
32+
33+
it("enableTwoFactor resolves the recovery codes through postRawRequestFull", async () => {
34+
const req = makeChainableRequest({ body: { recovery_codes: ["ABCD-1234"] } });
35+
request.post.mockReturnValue(req);
36+
37+
const { response } = await enableTwoFactor("email_otp");
38+
39+
expect(request.post).toHaveBeenCalledWith(window.ENABLE_TWO_FACTOR_ENDPOINT);
40+
expect(req.send).toHaveBeenCalledWith({ method: "email_otp" });
41+
expect(req.set).toHaveBeenCalledWith({ "X-CSRF-TOKEN": "test-csrf-token" });
42+
expect(response.recovery_codes).toEqual(["ABCD-1234"]);
43+
});
44+
45+
it("regenerateRecoveryCodes sends current_password only in the request body, never in the URL", async () => {
46+
const req = makeChainableRequest({ body: { recovery_codes: ["WXYZ-5678"] } });
47+
request.post.mockReturnValue(req);
48+
49+
const { response } = await regenerateRecoveryCodes("super-secret-password");
50+
51+
const calledUrl = request.post.mock.calls[0][0];
52+
expect(calledUrl).toBe(window.REGENERATE_RECOVERY_CODES_ENDPOINT);
53+
expect(calledUrl).not.toContain("super-secret-password");
54+
expect(req.send).toHaveBeenCalledWith({ current_password: "super-secret-password" });
55+
expect(response.recovery_codes).toEqual(["WXYZ-5678"]);
56+
});
57+
});

0 commit comments

Comments
 (0)