@@ -6,7 +6,11 @@ vi.mock("@/server/repositories/User", () => ({
66} ) ) ;
77vi . mock ( "@/server/utils/ratelimit" , async ( importOriginal ) => {
88 const actual = await importOriginal ( ) ;
9- return { ...( actual as object ) , checkLoginRateLimit : vi . fn ( ) } ;
9+ return {
10+ ...( actual as object ) ,
11+ checkLoginAttempt : vi . fn ( ) ,
12+ rollbackLoginAttempt : vi . fn ( ) ,
13+ } ;
1014} ) ;
1115vi . mock ( "@/auth/jwt" , ( ) => ( {
1216 setJWT : vi . fn ( ) ,
@@ -17,9 +21,13 @@ vi.mock("@/server/services/logger", () => ({
1721
1822import { POST } from "@/app/api/login/route" ;
1923import { findUserByEmailAndPassword } from "@/server/repositories/User" ;
20- import { checkLoginRateLimit } from "@/server/utils/ratelimit" ;
24+ import {
25+ checkLoginAttempt ,
26+ rollbackLoginAttempt ,
27+ } from "@/server/utils/ratelimit" ;
2128
22- const mockCheckLoginRateLimit = vi . mocked ( checkLoginRateLimit ) ;
29+ const mockCheckLoginAttempt = vi . mocked ( checkLoginAttempt ) ;
30+ const mockRollbackLoginAttempt = vi . mocked ( rollbackLoginAttempt ) ;
2331const mockFindUser = vi . mocked ( findUserByEmailAndPassword ) ;
2432
2533function makeRequest (
@@ -36,24 +44,25 @@ function makeRequest(
3644describe ( "POST /api/login" , ( ) => {
3745 beforeEach ( ( ) => {
3846 vi . clearAllMocks ( ) ;
39- mockCheckLoginRateLimit . mockResolvedValue ( true ) ;
47+ mockCheckLoginAttempt . mockResolvedValue ( true ) ;
48+ mockRollbackLoginAttempt . mockResolvedValue ( undefined ) ;
4049 mockFindUser . mockResolvedValue ( null ) ;
4150 } ) ;
4251
4352 describe ( "rate limiting" , ( ) => {
4453 test ( "allows request when rate limit is not exceeded" , async ( ) => {
45- mockCheckLoginRateLimit . mockResolvedValue ( true ) ;
54+ mockCheckLoginAttempt . mockResolvedValue ( true ) ;
4655 const request = makeRequest (
4756 { email : "user@example.com" , password : "pass" } ,
4857 { "x-forwarded-for" : "1.2.3.4" } ,
4958 ) ;
5059 const response = await POST ( request ) ;
5160 expect ( response . status ) . not . toBe ( 429 ) ;
52- expect ( mockCheckLoginRateLimit ) . toHaveBeenCalledWith ( "1.2.3.4" ) ;
61+ expect ( mockCheckLoginAttempt ) . toHaveBeenCalledWith ( "1.2.3.4" ) ;
5362 } ) ;
5463
5564 test ( "returns 429 when rate limit is exceeded" , async ( ) => {
56- mockCheckLoginRateLimit . mockResolvedValue ( false ) ;
65+ mockCheckLoginAttempt . mockResolvedValue ( false ) ;
5766 const request = makeRequest (
5867 { email : "user@example.com" , password : "pass" } ,
5968 { "x-forwarded-for" : "1.2.3.4" } ,
@@ -70,7 +79,7 @@ describe("POST /api/login", () => {
7079 { "x-forwarded-for" : "10.0.0.1, 10.0.0.2, 10.0.0.3" } ,
7180 ) ;
7281 await POST ( request ) ;
73- expect ( mockCheckLoginRateLimit ) . toHaveBeenCalledWith ( "10.0.0.1" ) ;
82+ expect ( mockCheckLoginAttempt ) . toHaveBeenCalledWith ( "10.0.0.1" ) ;
7483 } ) ;
7584
7685 test ( "trims whitespace from the first IP in x-forwarded-for" , async ( ) => {
@@ -79,7 +88,7 @@ describe("POST /api/login", () => {
7988 { "x-forwarded-for" : " 192.168.1.1 , 10.0.0.1" } ,
8089 ) ;
8190 await POST ( request ) ;
82- expect ( mockCheckLoginRateLimit ) . toHaveBeenCalledWith ( "192.168.1.1" ) ;
91+ expect ( mockCheckLoginAttempt ) . toHaveBeenCalledWith ( "192.168.1.1" ) ;
8392 } ) ;
8493
8594 test ( "falls back to x-real-ip when x-forwarded-for is absent" , async ( ) => {
@@ -88,7 +97,7 @@ describe("POST /api/login", () => {
8897 { "x-real-ip" : "5.6.7.8" } ,
8998 ) ;
9099 await POST ( request ) ;
91- expect ( mockCheckLoginRateLimit ) . toHaveBeenCalledWith ( "5.6.7.8" ) ;
100+ expect ( mockCheckLoginAttempt ) . toHaveBeenCalledWith ( "5.6.7.8" ) ;
92101 } ) ;
93102
94103 test ( 'uses "unknown" when no IP header is present' , async ( ) => {
@@ -97,7 +106,53 @@ describe("POST /api/login", () => {
97106 password : "pass" ,
98107 } ) ;
99108 await POST ( request ) ;
100- expect ( mockCheckLoginRateLimit ) . toHaveBeenCalledWith ( "unknown" ) ;
109+ expect ( mockCheckLoginAttempt ) . toHaveBeenCalledWith ( "unknown" ) ;
110+ } ) ;
111+
112+ test ( "rolls back the attempt on successful login" , async ( ) => {
113+ mockFindUser . mockResolvedValue ( {
114+ id : "1" ,
115+ email : "user@example.com" ,
116+ name : "User" ,
117+ createdAt : new Date ( ) ,
118+ passwordHash : "" ,
119+ avatarUrl : undefined ,
120+ } ) ;
121+ const request = makeRequest (
122+ { email : "user@example.com" , password : "correctpassword" } ,
123+ { "x-forwarded-for" : "1.2.3.4" } ,
124+ ) ;
125+ await POST ( request ) ;
126+ expect ( mockRollbackLoginAttempt ) . toHaveBeenCalledWith ( "1.2.3.4" ) ;
127+ } ) ;
128+
129+ test ( "does not roll back on invalid credentials" , async ( ) => {
130+ mockFindUser . mockResolvedValue ( null ) ;
131+ const request = makeRequest (
132+ { email : "user@example.com" , password : "wrongpassword" } ,
133+ { "x-forwarded-for" : "1.2.3.4" } ,
134+ ) ;
135+ const response = await POST ( request ) ;
136+ expect ( response . status ) . toBe ( 401 ) ;
137+ expect ( mockRollbackLoginAttempt ) . not . toHaveBeenCalled ( ) ;
138+ } ) ;
139+
140+ test ( "rolls back the attempt on invalid request body" , async ( ) => {
141+ const request = makeRequest ( { email : "not-an-email" , password : "" } ) ;
142+ const response = await POST ( request ) ;
143+ expect ( response . status ) . toBe ( 400 ) ;
144+ expect ( mockRollbackLoginAttempt ) . toHaveBeenCalledWith ( "unknown" ) ;
145+ } ) ;
146+
147+ test ( "rolls back the attempt on unexpected server error" , async ( ) => {
148+ mockFindUser . mockRejectedValue ( new Error ( "db failure" ) ) ;
149+ const request = makeRequest (
150+ { email : "user@example.com" , password : "pass" } ,
151+ { "x-forwarded-for" : "1.2.3.4" } ,
152+ ) ;
153+ const response = await POST ( request ) ;
154+ expect ( response . status ) . toBe ( 500 ) ;
155+ expect ( mockRollbackLoginAttempt ) . toHaveBeenCalledWith ( "1.2.3.4" ) ;
101156 } ) ;
102157 } ) ;
103158
0 commit comments