Skip to content

Commit f44908c

Browse files
committed
test: add unit tests for OTP verification with per-IP tracking in UsersService
1 parent b19d2fc commit f44908c

File tree

1 file changed

+80
-2
lines changed

1 file changed

+80
-2
lines changed

src/services/identity/__tests__/UsersService.spec.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { ModelStatic } from "sequelize/types"
22
import { Sequelize } from "sequelize-typescript"
33

4+
import { config } from "@root/config/config"
45
import { Otp, User, Whitelist } from "@root/database/models"
6+
import { BadRequestError } from "@root/errors/BadRequestError"
57
import SmsClient from "@services/identity/SmsClient"
68
import TotpGenerator from "@services/identity/TotpGenerator"
79
import MailClient from "@services/utilServices/MailClient"
@@ -39,8 +41,30 @@ const MockWhitelist = {
3941
findAll: jest.fn(),
4042
}
4143

44+
let dbAttempts = 0
45+
let dbAttemptsByIp: Record<string, number> = {}
46+
47+
const futureDate = new Date(Date.now() + 60 * 60 * 1000)
48+
4249
const MockOtp = {
43-
findOne: jest.fn(),
50+
findOne: jest.fn().mockImplementation(() =>
51+
Promise.resolve({
52+
id: 1,
53+
email: mockEmail,
54+
hashedOtp: "hashed",
55+
attempts: dbAttempts,
56+
attemptsByIp: { ...dbAttemptsByIp },
57+
expiresAt: futureDate,
58+
destroy: jest.fn(),
59+
})
60+
),
61+
update: jest.fn().mockImplementation((values: any, options: any) => {
62+
if (options?.where?.attempts !== dbAttempts) return Promise.resolve([0])
63+
64+
dbAttempts = values.attempts
65+
dbAttemptsByIp = { ...(values.attemptsByIp || {}) }
66+
return Promise.resolve([1])
67+
}),
4468
}
4569

4670
const UsersService = new _UsersService({
@@ -57,7 +81,11 @@ const mockEmail = "[email protected]"
5781
const mockGithubId = "sudowoodo"
5882

5983
describe("User Service", () => {
60-
afterEach(() => jest.clearAllMocks())
84+
afterEach(() => {
85+
jest.clearAllMocks()
86+
dbAttempts = 0
87+
dbAttemptsByIp = {}
88+
})
6189

6290
it("should return the result of calling the findOne method by email on the db model", () => {
6391
// Arrange
@@ -74,6 +102,56 @@ describe("User Service", () => {
74102
})
75103
})
76104

105+
describe("verifyOtp per-IP behavior", () => {
106+
const maxAttempts = config.get("auth.maxNumOtpAttempts")
107+
const wrongOtp = "000000"
108+
109+
it("increments attempts for provided IP and returns invalid until max", async () => {
110+
(MockOtpService.verifyOtp as jest.Mock).mockResolvedValue(false)
111+
const ip = "1.1.1.1"
112+
113+
for (let i = 1; i <= maxAttempts; i += 1) {
114+
// eslint-disable-next-line no-await-in-loop
115+
const result = await UsersService.verifyEmailOtp(mockEmail, wrongOtp, ip)
116+
expect(result.isErr()).toBe(true)
117+
const err = result._unsafeUnwrapErr()
118+
expect(err).toBeInstanceOf(BadRequestError)
119+
if (i < maxAttempts) {
120+
expect((err as BadRequestError).message).toBe("OTP is not valid")
121+
} else {
122+
expect((err as BadRequestError).message).toBe(
123+
"Max number of attempts reached"
124+
)
125+
}
126+
}
127+
128+
expect(MockOtp.update).toHaveBeenCalled()
129+
expect(dbAttemptsByIp[ip]).toBe(maxAttempts - 1) // last call rejected before increment
130+
})
131+
132+
it("uses 'unknown' bucket when clientIp is missing", async () => {
133+
(MockOtpService.verifyOtp as jest.Mock).mockResolvedValue(false)
134+
135+
const result = await UsersService.verifyEmailOtp(mockEmail, wrongOtp)
136+
expect(result.isErr()).toBe(true)
137+
result._unsafeUnwrapErr() // ensure unwrap doesn't throw
138+
expect(dbAttemptsByIp.unknown).toBe(1)
139+
})
140+
141+
it("tracks per-IP separately", async () => {
142+
(MockOtpService.verifyOtp as jest.Mock).mockResolvedValue(false)
143+
const ipA = "1.1.1.1"
144+
const ipB = "2.2.2.2"
145+
146+
await UsersService.verifyEmailOtp(mockEmail, wrongOtp, ipA)
147+
await UsersService.verifyEmailOtp(mockEmail, wrongOtp, ipA)
148+
await UsersService.verifyEmailOtp(mockEmail, wrongOtp, ipB)
149+
150+
expect(dbAttemptsByIp[ipA]).toBe(2)
151+
expect(dbAttemptsByIp[ipB]).toBe(1)
152+
})
153+
})
154+
77155
it("should return the result of calling the findOne method by githubId on the db model", () => {
78156
// Arrange
79157
const expected = "user1"

0 commit comments

Comments
 (0)