Skip to content

fix: require OTP verification for recovery unlock#115

Merged
Junman140 merged 2 commits intoPi-Defi-world:mainfrom
Yusufolosun:fix/recovery-unlock-two-factor
Mar 29, 2026
Merged

fix: require OTP verification for recovery unlock#115
Junman140 merged 2 commits intoPi-Defi-world:mainfrom
Yusufolosun:fix/recovery-unlock-two-factor

Conversation

@Yusufolosun
Copy link
Copy Markdown
Contributor

@Yusufolosun Yusufolosun commented Mar 28, 2026

Summary

Closes #105

The /recovery/unlock endpoint allowed account recovery with only identifier and passcode, bypassing two-factor authentication. This created a security vulnerability where stolen or weak passcodes enabled account takeover.

Root Cause

The unlockApp function immediately issued an API key after verifying identifier and passcode, without requiring a second authentication factor (OTP).

Fix Implemented

Implemented two-factor authentication for recovery unlock following the existing 2FA pattern from authService.ts:

Changes:

  • Split recovery unlock into two phases:
    1. POST /recovery/unlock: Verify identifier + passcode, generate OTP, return challenge token
    2. POST /recovery/unlock/verify: Verify challenge token + OTP, issue API key
  • Reused existing infrastructure: OtpChallenge table, RabbitMQ queue, JWT challenge tokens
  • OTP delivered via SMS or email based on identifier type
  • 10-minute OTP expiry with single-use enforcement

New Flow:

  1. Client submits identifier + passcode to /recovery/unlock
  2. Server verifies credentials, generates 6-digit OTP, sends via SMS/email
  3. Server returns { challenge_token, channel }
  4. Client submits challenge token + OTP to /recovery/unlock/verify
  5. Server validates OTP, marks as used, issues API key

Testing Performed

Added comprehensive test suite in tests/recovery.test.ts:

  • Invalid passcode rejection
  • Non-existent user handling
  • Challenge token generation and validation
  • OTP challenge record creation
  • Invalid/expired OTP rejection
  • OTP reuse prevention
  • API key issuance after successful verification

CI Confirmation

All changes follow existing code patterns and TypeScript typing. The implementation:

  • Reuses existing utilities (no new dependencies)
  • Follows established 2FA pattern from authService.ts
  • Maintains backward compatibility with error handling
  • Preserves rate limiting on recovery endpoints

Summary by CodeRabbit

  • New Features

    • Recovery changed to a two-step unlock flow: initial unlock returns a challenge token and delivery channel; a separate verify step returns the API key.
    • OTP delivery supported via email and SMS.
  • Bug Fixes

    • Improved error handling and clearer HTTP status mapping for recovery failures, missing delivery configuration, and OTP delivery issues.
  • Tests

    • Added tests covering unlock and OTP verification flows, including success and failure cases.

@drips-wave
Copy link
Copy Markdown

drips-wave bot commented Mar 28, 2026

@Yusufolosun Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 28, 2026

📝 Walkthrough

Walkthrough

Implements a two-step recovery unlock: POST /recovery/unlock verifies identifier+passcode and returns { challenge_token, channel }; an OTP is generated and published. POST /recovery/unlock/verify validates { challenge_token, code }, marks the challenge used, and returns { api_key, user_id }.

Changes

Cohort / File(s) Summary
Recovery Controller
src/controllers/recoveryController.ts
postUnlock now returns { challenge_token, channel } and maps new errors (Recovery channel not configured → 400, OTP delivery unavailable → 503). Added exported postUnlockVerify to validate { challenge_token, code } and return { api_key, user_id }; maps OTP-related errors to 401.
Recovery Routes
src/routes/recoveryRoutes.ts
Imported postUnlockVerify and added POST /recovery/unlock/verify route; retained existing /unlock route using postUnlock.
Recovery Service
src/services/recovery/recoveryService.ts
Refactored unlockApp to generate a 6-digit OTP, persist hashed challenge with 10-min expiry, sign a challenge token, publish OTP via RabbitMQ, and return { challenge_token, channel }. Added verifyRecoveryOtp to verify token, validate OTP against stored hash, mark challenge used, and issue { api_key, user_id }. Added JWT challenge token integration, OTP constants, and error cases for missing channel and publish failures.
Recovery Tests
tests/recovery.test.ts
New Jest tests that mock Prisma, JWT helpers, RabbitMQ channel, API key generation, and logger. Tests cover successful and failing paths for unlockApp and verifyRecoveryOtp, including OTP creation, publish behavior, invalid passcode, invalid code, and marking challenge as used.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Controller
    participant Service
    participant Database
    participant RabbitMQ
    participant JWT

    rect rgba(100, 150, 255, 0.5)
    Note over Client,JWT: Step 1 — Unlock Request
    Client->>Controller: POST /recovery/unlock\n{ identifier, passcode }
    Controller->>Service: unlockApp({ identifier, passcode })
    Service->>Database: Find user by identifier
    Service->>Service: Verify passcode hash
    Service->>Service: Generate 6-digit OTP
    Service->>Database: Store hashed OTP challenge (10-min expiry)
    Service->>JWT: Sign challenge token
    Service->>RabbitMQ: Publish OTP to queue (email/sms)
    Service-->>Controller: { challenge_token, channel }
    Controller-->>Client: 200 OK\n{ challenge_token, channel }
    end

    rect rgba(150, 200, 100, 0.5)
    Note over Client,JWT: Step 2 — OTP Verification
    Client->>Controller: POST /recovery/unlock/verify\n{ challenge_token, code }
    Controller->>Service: verifyRecoveryOtp({ challenge_token, code })
    Service->>JWT: Verify & decode challenge token
    Service->>Database: Lookup latest unused, non-expired OTP challenge
    Service->>Service: Verify OTP code hash
    Service->>Database: Mark OTP challenge as used
    Service->>Service: Issue new API key
    Service-->>Controller: { api_key, user_id }
    Controller-->>Client: 200 OK\n{ api_key, user_id }
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped to check the unlock door,
Sent a code and danced across the floor,
A challenge token held my paw,
Then OTP sealed the magic law,
Now keys and safety hop once more.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: converting the recovery unlock flow from single-factor to requiring OTP verification.
Linked Issues check ✅ Passed The PR fully implements the objective from issue #105 by converting the recovery unlock into a two-step process requiring OTP verification, eliminating the single-factor security vulnerability.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing two-factor OTP verification for recovery unlock as specified in issue #105; no unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/controllers/recoveryController.ts`:
- Around line 87-93: Normalize the error thrown by the challenge verification so
the controller can reliably map it to a 401: update verifyChallengeToken in the
recoveryService (and any calls in src/utils/jwt.ts) to throw a standardized
error (e.g., throw new AppError("Invalid or expired challenge", 401)) for all
challenge-related failures, or alternatively expand the recoveryController.ts
check around the e.message (the block with e.message === "Invalid or expired
challenge") to treat any message containing the word "challenge"
(case-insensitive) or the specific messages emitted by verifyChallengeToken/jwt
utilities as a 401; reference verifyChallengeToken, recoveryService, and the jwt
utility when making the change so all challenge-failure paths are consistently
mapped to next(new AppError(..., 401)).

In `@src/services/recovery/recoveryService.ts`:
- Line 12: OTP_EXPIRY_MINUTES is set to 10 but the challenge JWT in
src/utils/jwt.ts is hardcoded to "5m", causing the challenge token to expire
before the OTP row; update the code so both expiries come from the same source:
either export OTP_EXPIRY_MINUTES (or a derived constant like OTP_TTL) and use it
to build the JWT expiry in the jwt helper instead of "5m", or modify the
recovery service to pass the OTP TTL into the JWT creation function; adjust all
uses (e.g., OTP_EXPIRY_MINUTES, the JWT helper function that issues the
challenge token, and any calling code in the recovery/unlock flow) so they read
the same TTL value.
- Around line 138-160: The OTP redeem flow is raceable because you read with
prisma.otpChallenge.findFirst then unconditionally call
prisma.otpChallenge.update, allowing two concurrent requests to both succeed;
change the claim to an atomic conditional update: replace the
prisma.otpChallenge.update call with an atomic operation (e.g.,
prisma.otpChallenge.updateMany or an equivalent) that includes usedAt: null (and
still validates expiresAt if desired) in the WHERE clause, then check the
returned count/affectedRows and throw “Invalid or expired code” if it is not
exactly 1 so only the caller that actually claimed the row proceeds to issue API
keys; keep the bcrypt.compare step and only attempt the atomic claim after a
successful password match, and abort if the conditional update did not affect
exactly one row.
- Around line 34-36: The generateOtpCode function uses Math.random which is not
cryptographically secure; replace it with the Node crypto CSPRNG by importing
randomInt from "crypto" and using randomInt to produce a 6-digit number (e.g.,
randomInt(100000, 1000000)) inside generateOtpCode so it returns a securely
generated 6-digit string; update the import and the body of generateOtpCode
accordingly.

In `@tests/recovery.test.ts`:
- Around line 107-125: The test seeds an OTP row via prisma.otpChallenge.create
before calling unlockApp(), but verifyRecoveryOtp() (in
src/services/recovery/recoveryService.ts) always loads the latest unused
challenge for the user, so the OTP "123456" doesn't match the challenge tied to
the challengeToken returned by unlockApp(); fix by obtaining the actual
OTP/challenge produced by the RabbitMQ mock or by making OTP generation
deterministic and reusing the same challengeToken for both verification attempts
(i.e., stop creating a separate prisma.otpChallenge entry and instead capture
the OTP/challenge emitted by the RabbitMQ mock or force unlockApp()/challenge
creation to use the seeded value so verifyRecoveryOtp() validates against the
correct challengeToken).
- Around line 136-145: The test uses a handcrafted JWT which only proves
rejection of malformed tokens; replace it with a legitimately-signed challenge
token so expiry is actually exercised: call signChallengeToken (or the same
token-creation helper used in production) to create a token with a short TTL or
explicitly set exp to a past timestamp, then advance/freeze time (or set exp) so
the token is expired and call verifyRecoveryOtp({ challenge_token: <that token>,
code: otpCode }) expecting a rejection; reference signChallengeToken and
verifyRecoveryOtp to locate where to create and verify the real expired token.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 90c2a064-8727-4cf6-8814-b2d945619dd7

📥 Commits

Reviewing files that changed from the base of the PR and between 9dfff0f and b544708.

📒 Files selected for processing (4)
  • src/controllers/recoveryController.ts
  • src/routes/recoveryRoutes.ts
  • src/services/recovery/recoveryService.ts
  • tests/recovery.test.ts

Comment on lines +87 to +93
if (
e.message === "Invalid or expired code" ||
e.message === "Invalid code"
)
return next(new AppError(e.message, 401));
if (e.message === "Invalid or expired challenge")
return next(new AppError(e.message, 401));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bad challenge tokens will miss this 401 mapping.

src/services/recovery/recoveryService.ts, Line 135, forwards verifyChallengeToken() failures verbatim, but this block only matches "Invalid or expired challenge". Even the wrong-purpose case from src/utils/jwt.ts, Lines 33-35, misses this mapping, so bad challenge tokens can fall through to the generic error path instead of returning the intended 401. Normalize the service error or widen this branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/controllers/recoveryController.ts` around lines 87 - 93, Normalize the
error thrown by the challenge verification so the controller can reliably map it
to a 401: update verifyChallengeToken in the recoveryService (and any calls in
src/utils/jwt.ts) to throw a standardized error (e.g., throw new
AppError("Invalid or expired challenge", 401)) for all challenge-related
failures, or alternatively expand the recoveryController.ts check around the
e.message (the block with e.message === "Invalid or expired challenge") to treat
any message containing the word "challenge" (case-insensitive) or the specific
messages emitted by verifyChallengeToken/jwt utilities as a 401; reference
verifyChallengeToken, recoveryService, and the jwt utility when making the
change so all challenge-failure paths are consistently mapped to next(new
AppError(..., 401)).

import { signChallengeToken, verifyChallengeToken } from "../../utils/jwt";
import { getRabbitMQChannel, QUEUES } from "../../config/rabbitmq";

const OTP_EXPIRY_MINUTES = 10;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The challenge token expires five minutes before the OTP does.

OTP_EXPIRY_MINUTES is 10 here, but src/utils/jwt.ts, Line 8, hardcodes the challenge JWT to 5m. After minute 5 the OTP row is still valid while /recovery/unlock/verify can no longer redeem it, which cuts the advertised recovery window in half. Drive both expiries from the same constant or pass the OTP TTL into the JWT helper.

Also applies to: 119-125

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/recovery/recoveryService.ts` at line 12, OTP_EXPIRY_MINUTES is
set to 10 but the challenge JWT in src/utils/jwt.ts is hardcoded to "5m",
causing the challenge token to expire before the OTP row; update the code so
both expiries come from the same source: either export OTP_EXPIRY_MINUTES (or a
derived constant like OTP_TTL) and use it to build the JWT expiry in the jwt
helper instead of "5m", or modify the recovery service to pass the OTP TTL into
the JWT creation function; adjust all uses (e.g., OTP_EXPIRY_MINUTES, the JWT
helper function that issues the challenge token, and any calling code in the
recovery/unlock flow) so they read the same TTL value.

Comment on lines +34 to +36
function generateOtpCode(): string {
return String(Math.floor(100000 + Math.random() * 900000));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/services/recovery/recoveryService.ts | head -50

Repository: Pi-Defi-world/acbu-backend

Length of output: 1814


🏁 Script executed:

cat package.json | grep -A 2 -B 2 '"engines"'

Repository: Pi-Defi-world/acbu-backend

Length of output: 52


🏁 Script executed:

rg "Math\.random|crypto\.randomInt|randomInt" src/services/recovery/

Repository: Pi-Defi-world/acbu-backend

Length of output: 172


🏁 Script executed:

cat package.json | grep -E '"node"|"engines"' || echo "Node version info not found in root package.json"

Repository: Pi-Defi-world/acbu-backend

Length of output: 118


🏁 Script executed:

ls -la | grep -E "\.nvmrc|\.node-version" || echo "No node version files found"

Repository: Pi-Defi-world/acbu-backend

Length of output: 97


🏁 Script executed:

# Check if crypto.randomInt is used elsewhere in the codebase
rg "randomInt|crypto\.(random|secure)" --type ts --type js

Repository: Pi-Defi-world/acbu-backend

Length of output: 318


Use a CSPRNG for recovery OTPs.

Math.random() is not cryptographically secure; predictability weakens the second factor. Replace with crypto.randomInt() to generate 6-digit OTPs. The codebase already uses crypto for secure random values elsewhere (e.g., crypto.randomBytes() in auth middleware and user controller).

Suggested fix
 function generateOtpCode(): string {
-  return String(Math.floor(100000 + Math.random() * 900000));
+  return String(randomInt(100000, 1_000_000));
 }

Add to imports:

import { randomInt } from "crypto";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/recovery/recoveryService.ts` around lines 34 - 36, The
generateOtpCode function uses Math.random which is not cryptographically secure;
replace it with the Node crypto CSPRNG by importing randomInt from "crypto" and
using randomInt to produce a 6-digit number (e.g., randomInt(100000, 1000000))
inside generateOtpCode so it returns a securely generated 6-digit string; update
the import and the body of generateOtpCode accordingly.

Comment on lines +138 to +160
const challenge = await prisma.otpChallenge.findFirst({
where: {
userId: payload.userId,
expiresAt: { gt: now },
usedAt: null,
},
orderBy: { createdAt: "desc" },
});

if (!challenge) {
throw new Error("Invalid or expired code");
}

const match = await bcrypt.compare(code, challenge.codeHash);
if (!match) {
logger.warn("Recovery: invalid OTP", { userId: payload.userId });
throw new Error("Invalid code");
}

await prisma.otpChallenge.update({
where: { id: challenge.id },
data: { usedAt: now },
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Redeeming the OTP is raceable.

This is a read-then-write flow, and the update() at Lines 157-160 only matches by id. Two concurrent verify requests can both read the same unused challenge before either write happens, then both update it and both issue API keys. That breaks the single-use guarantee from the PR. Claim the row atomically and only continue when exactly one row was updated.

🧵 Proposed fix
-  await prisma.otpChallenge.update({
-    where: { id: challenge.id },
-    data: { usedAt: now },
-  });
+  const claimed = await prisma.otpChallenge.updateMany({
+    where: {
+      id: challenge.id,
+      usedAt: null,
+      expiresAt: { gt: now },
+    },
+    data: { usedAt: now },
+  });
+  if (claimed.count !== 1) {
+    throw new Error("Invalid or expired code");
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const challenge = await prisma.otpChallenge.findFirst({
where: {
userId: payload.userId,
expiresAt: { gt: now },
usedAt: null,
},
orderBy: { createdAt: "desc" },
});
if (!challenge) {
throw new Error("Invalid or expired code");
}
const match = await bcrypt.compare(code, challenge.codeHash);
if (!match) {
logger.warn("Recovery: invalid OTP", { userId: payload.userId });
throw new Error("Invalid code");
}
await prisma.otpChallenge.update({
where: { id: challenge.id },
data: { usedAt: now },
});
const challenge = await prisma.otpChallenge.findFirst({
where: {
userId: payload.userId,
expiresAt: { gt: now },
usedAt: null,
},
orderBy: { createdAt: "desc" },
});
if (!challenge) {
throw new Error("Invalid or expired code");
}
const match = await bcrypt.compare(code, challenge.codeHash);
if (!match) {
logger.warn("Recovery: invalid OTP", { userId: payload.userId });
throw new Error("Invalid code");
}
const claimed = await prisma.otpChallenge.updateMany({
where: {
id: challenge.id,
usedAt: null,
expiresAt: { gt: now },
},
data: { usedAt: now },
});
if (claimed.count !== 1) {
throw new Error("Invalid or expired code");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/recovery/recoveryService.ts` around lines 138 - 160, The OTP
redeem flow is raceable because you read with prisma.otpChallenge.findFirst then
unconditionally call prisma.otpChallenge.update, allowing two concurrent
requests to both succeed; change the claim to an atomic conditional update:
replace the prisma.otpChallenge.update call with an atomic operation (e.g.,
prisma.otpChallenge.updateMany or an equivalent) that includes usedAt: null (and
still validates expiresAt if desired) in the WHERE clause, then check the
returned count/affectedRows and throw “Invalid or expired code” if it is not
exactly 1 so only the caller that actually claimed the row proceeds to issue API
keys; keep the bcrypt.compare step and only attempt the atomic claim after a
successful password match, and abort if the conditional update did not affect
exactly one row.

Comment on lines +136 to +145
it("should reject expired challenge token", async () => {
const expiredToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0IiwicHVycG9zZSI6InNpZ25pbl8yZmEiLCJpYXQiOjE2MDk0NTkyMDAsImV4cCI6MTYwOTQ1OTIwMH0.test";

await expect(
verifyRecoveryOtp({
challenge_token: expiredToken,
code: otpCode,
}),
).rejects.toThrow();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

This test is not actually proving the expiry path.

expiredToken is a hand-written string, not one minted by signChallengeToken(), so this only shows that invalid challenge tokens are rejected. If you want expiry coverage here, generate a real challenge token and advance time past its TTL, or sign one with an already-expired exp.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/recovery.test.ts` around lines 136 - 145, The test uses a handcrafted
JWT which only proves rejection of malformed tokens; replace it with a
legitimately-signed challenge token so expiry is actually exercised: call
signChallengeToken (or the same token-creation helper used in production) to
create a token with a short TTL or explicitly set exp to a past timestamp, then
advance/freeze time (or set exp) so the token is expired and call
verifyRecoveryOtp({ challenge_token: <that token>, code: otpCode }) expecting a
rejection; reference signChallengeToken and verifyRecoveryOtp to locate where to
create and verify the real expired token.

@Junman140
Copy link
Copy Markdown
Member

@Yusufolosun cl failed

Replace DB-coupled recovery tests with isolated unit tests so CI does not depend on a live database for this suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Yusufolosun
Copy link
Copy Markdown
Contributor Author

@Junman140
CI workflow failure fixed and ready for review

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/recovery.test.ts (1)

83-85: Strengthen OTP creation assertion in unlock test.

Line 83 only checks call count. Asserting key fields (userId, channel, expiresAt) will better protect against silent security regressions.

🔍 Stronger assertion example
-      expect(mockPrismaOtpCreate).toHaveBeenCalledTimes(1);
+      expect(mockPrismaOtpCreate).toHaveBeenCalledWith(
+        expect.objectContaining({
+          data: expect.objectContaining({
+            userId: "user-1",
+            channel: "email",
+            expiresAt: expect.any(Date),
+          }),
+        }),
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/recovery.test.ts` around lines 83 - 85, The test currently only checks
mockPrismaOtpCreate call count; update it to assert the OTP creation payload
contains the correct userId and channel and a valid future expiresAt. Replace or
augment the call-count assertion for mockPrismaOtpCreate with a call-argument
check (e.g.
expect(mockPrismaOtpCreate).toHaveBeenCalledWith/expect.objectContaining) that
verifies userId === "user-1", channel === the expected channel (e.g. "sms" or
"email" used in the test), and that expiresAt is a Date/number in the future
(compare to Date.now() or use expect.any(Date) plus a > check). Keep
mockSignChallengeToken assertion as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/recovery.test.ts`:
- Around line 101-137: Add two regression tests for verifyRecoveryOtp: one that
verifies a previously used OTP is rejected by mocking mockPrismaOtpFindFirst to
return a record with usedAt set (e.g., usedAt: new Date()) and asserting
verifyRecoveryOtp rejects, mockPrismaOtpUpdate is not called, and
mockGenerateApiKey is not called; and another that verifies expired/no-active
challenges are rejected by making mockVerifyChallengeToken return null or throw
(simulating an expired challenge) and asserting verifyRecoveryOtp rejects and
mockGenerateApiKey/mockPrismaOtpUpdate are not called. Ensure you reference the
existing helpers (verifyRecoveryOtp, mockVerifyChallengeToken,
mockPrismaOtpFindFirst, mockPrismaOtpUpdate, mockGenerateApiKey) when adding
these tests.

---

Nitpick comments:
In `@tests/recovery.test.ts`:
- Around line 83-85: The test currently only checks mockPrismaOtpCreate call
count; update it to assert the OTP creation payload contains the correct userId
and channel and a valid future expiresAt. Replace or augment the call-count
assertion for mockPrismaOtpCreate with a call-argument check (e.g.
expect(mockPrismaOtpCreate).toHaveBeenCalledWith/expect.objectContaining) that
verifies userId === "user-1", channel === the expected channel (e.g. "sms" or
"email" used in the test), and that expiresAt is a Date/number in the future
(compare to Date.now() or use expect.any(Date) plus a > check). Keep
mockSignChallengeToken assertion as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: da23d6e7-62d0-4875-b038-646d047b5bc3

📥 Commits

Reviewing files that changed from the base of the PR and between b544708 and 0de3387.

📒 Files selected for processing (1)
  • tests/recovery.test.ts

Comment on lines +101 to +137
describe("verifyRecoveryOtp", () => {
it("issues API key on valid OTP", async () => {
mockVerifyChallengeToken.mockReturnValue({ userId: "user-1" });
mockPrismaOtpFindFirst.mockResolvedValue({
id: "otp-1",
codeHash: await bcrypt.hash("111111", 10),
});
mockGenerateApiKey.mockResolvedValue("api-key-1");

const out = await verifyRecoveryOtp({
challenge_token: "challenge-token",
code: "111111",
});

expect(out).toEqual({ api_key: "api-key-1", user_id: "user-1" });
expect(mockPrismaOtpUpdate).toHaveBeenCalledWith({
where: { id: "otp-1" },
data: { usedAt: expect.any(Date) },
});
expect(mockGenerateApiKey).toHaveBeenCalledWith("user-1", []);
});

it("rejects invalid OTP", async () => {
mockVerifyChallengeToken.mockReturnValue({ userId: "user-1" });
mockPrismaOtpFindFirst.mockResolvedValue({
id: "otp-1",
codeHash: await bcrypt.hash("111111", 10),
});

await expect(
verifyRecoveryOtp({
challenge_token: "challenge-token",
code: "222222",
}),
).rejects.toThrow("Invalid code");
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing regression tests for OTP single-use and expiry enforcement.

Lines 101-137 only validate success/invalid-code paths. They don’t assert that a used OTP cannot be reused and that expired/no-active challenges are rejected, which are core security guarantees for this flow.

✅ Suggested test additions
+    it("rejects OTP reuse", async () => {
+      mockVerifyChallengeToken.mockReturnValue({ userId: "user-1" });
+      const codeHash = await bcrypt.hash("111111", 10);
+      mockPrismaOtpFindFirst
+        .mockResolvedValueOnce({ id: "otp-1", codeHash }) // first verify
+        .mockResolvedValueOnce(null); // second verify should see no active challenge
+      mockGenerateApiKey.mockResolvedValue("api-key-1");
+
+      await verifyRecoveryOtp({ challenge_token: "challenge-token", code: "111111" });
+      await expect(
+        verifyRecoveryOtp({ challenge_token: "challenge-token", code: "111111" }),
+      ).rejects.toThrow();
+    });
+
+    it("rejects expired/missing active OTP challenge", async () => {
+      mockVerifyChallengeToken.mockReturnValue({ userId: "user-1" });
+      mockPrismaOtpFindFirst.mockResolvedValue(null);
+
+      await expect(
+        verifyRecoveryOtp({ challenge_token: "challenge-token", code: "111111" }),
+      ).rejects.toThrow();
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/recovery.test.ts` around lines 101 - 137, Add two regression tests for
verifyRecoveryOtp: one that verifies a previously used OTP is rejected by
mocking mockPrismaOtpFindFirst to return a record with usedAt set (e.g., usedAt:
new Date()) and asserting verifyRecoveryOtp rejects, mockPrismaOtpUpdate is not
called, and mockGenerateApiKey is not called; and another that verifies
expired/no-active challenges are rejected by making mockVerifyChallengeToken
return null or throw (simulating an expired challenge) and asserting
verifyRecoveryOtp rejects and mockGenerateApiKey/mockPrismaOtpUpdate are not
called. Ensure you reference the existing helpers (verifyRecoveryOtp,
mockVerifyChallengeToken, mockPrismaOtpFindFirst, mockPrismaOtpUpdate,
mockGenerateApiKey) when adding these tests.

@Junman140 Junman140 merged commit 4897c2b into Pi-Defi-world:main Mar 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recovery unlock is single-factor

2 participants