@@ -103,4 +103,120 @@ describe("useVerificationToken", () => {
103103 expect ( result ) . toBeNull ( ) ;
104104 expect ( deleted ) . toBe ( false ) ;
105105 } ) ;
106+
107+ it ( "prevents race condition by checking rowsAffected on token consumption" , async ( ) => {
108+ let deletePredicate1 : unknown = null ;
109+ const mockDb1 = {
110+ select : ( ) => ( {
111+ from : ( ) => ( {
112+ where : ( ) => ( {
113+ limit : async ( ) => [
114+ {
115+ identifier : "user@example.com" ,
116+ token : "123456" ,
117+ expires : new Date ( Date . now ( ) + 600000 ) ,
118+ } ,
119+ ] ,
120+ } ) ,
121+ } ) ,
122+ } ) ,
123+ delete : ( ) => ( {
124+ where : ( pred : unknown ) => {
125+ deletePredicate1 = pred ;
126+ return Promise . resolve ( { rowsAffected : 1 } ) ;
127+ } ,
128+ } ) ,
129+ } as unknown as MySql2Database ;
130+
131+ const adapter1 = DrizzleAdapter ( mockDb1 ) ;
132+
133+ let deletePredicate2 : unknown = null ;
134+ const mockDb2 = {
135+ select : ( ) => ( {
136+ from : ( ) => ( {
137+ where : ( ) => ( {
138+ limit : async ( ) => [
139+ {
140+ identifier : "user@example.com" ,
141+ token : "123456" ,
142+ expires : new Date ( Date . now ( ) + 600000 ) ,
143+ } ,
144+ ] ,
145+ } ) ,
146+ } ) ,
147+ } ) ,
148+ delete : ( ) => ( {
149+ where : ( pred : unknown ) => {
150+ deletePredicate2 = pred ;
151+ return Promise . resolve ( { rowsAffected : 0 } ) ;
152+ } ,
153+ } ) ,
154+ } as unknown as MySql2Database ;
155+
156+ const adapter2 = DrizzleAdapter ( mockDb2 ) ;
157+
158+ const firstResult = await adapter1 . useVerificationToken ?.( {
159+ identifier : "USER@example.com" ,
160+ token : "123456" ,
161+ } ) ;
162+
163+ expect ( firstResult ) . not . toBeNull ( ) ;
164+ expect ( deletePredicate1 ) . not . toBeNull ( ) ;
165+
166+ const secondResult = await adapter2 . useVerificationToken ?.( {
167+ identifier : "USER@example.com" ,
168+ token : "123456" ,
169+ } ) ;
170+
171+ expect ( secondResult ) . toBeNull ( ) ;
172+ expect ( deletePredicate2 ) . not . toBeNull ( ) ;
173+ } ) ;
174+
175+ it ( "deletes only the selected token instance and preserves replacement tokens for the same user" , async ( ) => {
176+ let table = [
177+ {
178+ identifier : "user@example.com" ,
179+ token : "123456" ,
180+ expires : new Date ( Date . now ( ) + 600000 ) ,
181+ } ,
182+ {
183+ identifier : "user@example.com" ,
184+ token : "replacement_token" ,
185+ expires : new Date ( Date . now ( ) + 600000 ) ,
186+ } ,
187+ ] ;
188+
189+ const mockDb = {
190+ select : ( ) => ( {
191+ from : ( ) => ( {
192+ where : ( ) => ( {
193+ limit : async ( ) => [ table [ 0 ] ] ,
194+ } ) ,
195+ } ) ,
196+ } ) ,
197+ delete : ( ) => ( {
198+ where : ( ) => {
199+ const initialCount = table . length ;
200+ table = table . filter (
201+ ( row ) =>
202+ ! (
203+ row . identifier === "user@example.com" && row . token === "123456"
204+ ) ,
205+ ) ;
206+ const rowsAffected = initialCount - table . length ;
207+ return Promise . resolve ( { rowsAffected } ) ;
208+ } ,
209+ } ) ,
210+ } as unknown as MySql2Database ;
211+
212+ const adapter = DrizzleAdapter ( mockDb ) ;
213+ const result = await adapter . useVerificationToken ?.( {
214+ identifier : "USER@example.com" ,
215+ token : "999999" ,
216+ } ) ;
217+
218+ expect ( result ) . toBeNull ( ) ;
219+ expect ( table . some ( ( r ) => r . token === "123456" ) ) . toBe ( false ) ;
220+ expect ( table . some ( ( r ) => r . token === "replacement_token" ) ) . toBe ( true ) ;
221+ } ) ;
106222} ) ;
0 commit comments