11import { describe , expect , it , vi } from "vitest" ;
2- import type { AuthRateLimiter } from "../../auth-rate-limit.js" ;
2+ import { createAuthRateLimiter , type AuthRateLimiter } from "../../auth-rate-limit.js" ;
33import { resolveConnectAuthDecision , type ConnectAuthState } from "./auth-context.js" ;
44
55type VerifyDeviceTokenFn = Parameters < typeof resolveConnectAuthDecision > [ 0 ] [ "verifyDeviceToken" ] ;
@@ -9,7 +9,9 @@ type VerifyBootstrapTokenFn = Parameters<
99
1010function createRateLimiter ( params ?: { allowed ?: boolean ; retryAfterMs ?: number } ) : {
1111 limiter : AuthRateLimiter ;
12+ check : ReturnType < typeof vi . fn > ;
1213 reset : ReturnType < typeof vi . fn > ;
14+ recordFailure : ReturnType < typeof vi . fn > ;
1315} {
1416 const allowed = params ?. allowed ?? true ;
1517 const retryAfterMs = params ?. retryAfterMs ?? 5_000 ;
@@ -22,7 +24,31 @@ function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }
2224 reset,
2325 recordFailure,
2426 } as unknown as AuthRateLimiter ,
27+ check,
2528 reset,
29+ recordFailure,
30+ } ;
31+ }
32+
33+ function createPerScopeRateLimiter (
34+ scopes : Record < string , { allowed : boolean ; retryAfterMs ?: number } > ,
35+ ) : {
36+ limiter : AuthRateLimiter ;
37+ check : ReturnType < typeof vi . fn > ;
38+ reset : ReturnType < typeof vi . fn > ;
39+ recordFailure : ReturnType < typeof vi . fn > ;
40+ } {
41+ const check = vi . fn ( ( _ip : string | undefined , scope ?: string ) => {
42+ const cfg = scopes [ scope ?? "" ] ?? { allowed : true } ;
43+ return { allowed : cfg . allowed , retryAfterMs : cfg . retryAfterMs ?? 5_000 } ;
44+ } ) ;
45+ const reset = vi . fn ( ) ;
46+ const recordFailure = vi . fn ( ) ;
47+ return {
48+ limiter : { check, reset, recordFailure } as unknown as AuthRateLimiter ,
49+ check,
50+ reset,
51+ recordFailure,
2652 } ;
2753}
2854
@@ -281,4 +307,179 @@ describe("resolveConnectAuthDecision", () => {
281307 expect ( verifyBootstrapToken ) . toHaveBeenCalledOnce ( ) ;
282308 expect ( verifyDeviceToken ) . not . toHaveBeenCalled ( ) ;
283309 } ) ;
310+
311+ it ( "gates bootstrap-token verify when the bootstrap-token bucket is exceeded" , async ( ) => {
312+ const rateLimiter = createPerScopeRateLimiter ( {
313+ "bootstrap-token" : { allowed : false , retryAfterMs : 30_000 } ,
314+ "device-token" : { allowed : true } ,
315+ "shared-secret" : { allowed : true } ,
316+ } ) ;
317+ const verifyBootstrapToken = vi . fn < VerifyBootstrapTokenFn > ( async ( ) => ( { ok : true } ) ) ;
318+ const verifyDeviceToken = vi . fn < VerifyDeviceTokenFn > ( async ( ) => ( { ok : true } ) ) ;
319+ const decision = await resolveDeviceTokenDecision ( {
320+ verifyBootstrapToken,
321+ verifyDeviceToken,
322+ rateLimiter : rateLimiter . limiter ,
323+ clientIp : "203.0.113.20" ,
324+ stateOverrides : {
325+ bootstrapTokenCandidate : "bootstrap-token" ,
326+ deviceTokenCandidate : undefined ,
327+ deviceTokenCandidateSource : undefined ,
328+ } ,
329+ } ) ;
330+ expect ( decision . authOk ) . toBe ( false ) ;
331+ expect ( decision . authResult . reason ) . toBe ( "rate_limited" ) ;
332+ expect ( decision . authResult . retryAfterMs ) . toBe ( 30_000 ) ;
333+ // The verify path is mutex-locked + does fs I/O — confirm we never invoke
334+ // it once the bucket is exhausted.
335+ expect ( verifyBootstrapToken ) . not . toHaveBeenCalled ( ) ;
336+ } ) ;
337+
338+ it ( "still verifies the device token when only the bootstrap-token path is rate-limited" , async ( ) => {
339+ const rateLimiter = createPerScopeRateLimiter ( {
340+ "bootstrap-token" : { allowed : false , retryAfterMs : 30_000 } ,
341+ "device-token" : { allowed : true } ,
342+ "shared-secret" : { allowed : true } ,
343+ } ) ;
344+ const verifyBootstrapToken = vi . fn < VerifyBootstrapTokenFn > ( async ( ) => ( { ok : true } ) ) ;
345+ const verifyDeviceToken = vi . fn < VerifyDeviceTokenFn > ( async ( ) => ( { ok : true } ) ) ;
346+ const decision = await resolveDeviceTokenDecision ( {
347+ verifyBootstrapToken,
348+ verifyDeviceToken,
349+ rateLimiter : rateLimiter . limiter ,
350+ clientIp : "203.0.113.20" ,
351+ stateOverrides : {
352+ bootstrapTokenCandidate : "bootstrap-token" ,
353+ deviceTokenCandidate : "device-token" ,
354+ } ,
355+ } ) ;
356+ expect ( decision . authOk ) . toBe ( true ) ;
357+ expect ( decision . authMethod ) . toBe ( "device-token" ) ;
358+ expect ( verifyBootstrapToken ) . not . toHaveBeenCalled ( ) ;
359+ expect ( verifyDeviceToken ) . toHaveBeenCalledOnce ( ) ;
360+ } ) ;
361+
362+ it ( "records a bootstrap-token failure when final auth rejects" , async ( ) => {
363+ const rateLimiter = createPerScopeRateLimiter ( {
364+ "bootstrap-token" : { allowed : true } ,
365+ "device-token" : { allowed : true } ,
366+ "shared-secret" : { allowed : true } ,
367+ } ) ;
368+ const verifyBootstrapToken = vi . fn < VerifyBootstrapTokenFn > ( async ( ) => ( {
369+ ok : false ,
370+ reason : "bootstrap_token_invalid" ,
371+ } ) ) ;
372+ const verifyDeviceToken = vi . fn < VerifyDeviceTokenFn > ( async ( ) => ( { ok : true } ) ) ;
373+ await resolveDeviceTokenDecision ( {
374+ verifyBootstrapToken,
375+ verifyDeviceToken,
376+ rateLimiter : rateLimiter . limiter ,
377+ clientIp : "203.0.113.20" ,
378+ stateOverrides : {
379+ bootstrapTokenCandidate : "bootstrap-token" ,
380+ deviceTokenCandidate : undefined ,
381+ deviceTokenCandidateSource : undefined ,
382+ } ,
383+ } ) ;
384+ expect ( rateLimiter . recordFailure ) . toHaveBeenCalledWith ( "203.0.113.20" , "bootstrap-token" ) ;
385+ expect ( rateLimiter . reset ) . not . toHaveBeenCalledWith ( "203.0.113.20" , "bootstrap-token" ) ;
386+ } ) ;
387+
388+ it ( "does not record a bootstrap-token failure when device-token fallback succeeds" , async ( ) => {
389+ const rateLimiter = createPerScopeRateLimiter ( {
390+ "bootstrap-token" : { allowed : true } ,
391+ "device-token" : { allowed : true } ,
392+ "shared-secret" : { allowed : true } ,
393+ } ) ;
394+ const verifyBootstrapToken = vi . fn < VerifyBootstrapTokenFn > ( async ( ) => ( {
395+ ok : false ,
396+ reason : "bootstrap_token_invalid" ,
397+ } ) ) ;
398+ const verifyDeviceToken = vi . fn < VerifyDeviceTokenFn > ( async ( ) => ( { ok : true } ) ) ;
399+ const decision = await resolveDeviceTokenDecision ( {
400+ verifyBootstrapToken,
401+ verifyDeviceToken,
402+ rateLimiter : rateLimiter . limiter ,
403+ clientIp : "203.0.113.20" ,
404+ stateOverrides : {
405+ bootstrapTokenCandidate : "bootstrap-token" ,
406+ deviceTokenCandidate : "device-token" ,
407+ } ,
408+ } ) ;
409+ expect ( decision . authOk ) . toBe ( true ) ;
410+ expect ( decision . authMethod ) . toBe ( "device-token" ) ;
411+ expect ( rateLimiter . recordFailure ) . not . toHaveBeenCalledWith ( "203.0.113.20" , "bootstrap-token" ) ;
412+ } ) ;
413+
414+ it ( "serializes concurrent bootstrap-token failures before checking the next attempt" , async ( ) => {
415+ const rateLimiter = createAuthRateLimiter ( {
416+ maxAttempts : 3 ,
417+ windowMs : 60_000 ,
418+ lockoutMs : 60_000 ,
419+ exemptLoopback : false ,
420+ pruneIntervalMs : 0 ,
421+ } ) ;
422+ let activeBootstrapChecks = 0 ;
423+ let maxActiveBootstrapChecks = 0 ;
424+ const verifyBootstrapToken = vi . fn < VerifyBootstrapTokenFn > ( async ( ) => {
425+ activeBootstrapChecks += 1 ;
426+ maxActiveBootstrapChecks = Math . max ( maxActiveBootstrapChecks , activeBootstrapChecks ) ;
427+ await new Promise ( ( resolve ) => setTimeout ( resolve , 5 ) ) ;
428+ activeBootstrapChecks -= 1 ;
429+ return { ok : false , reason : "bootstrap_token_invalid" } ;
430+ } ) ;
431+ const verifyDeviceToken = vi . fn < VerifyDeviceTokenFn > ( async ( ) => ( { ok : true } ) ) ;
432+ try {
433+ const decisions = await Promise . all (
434+ Array . from (
435+ { length : 8 } ,
436+ async ( ) =>
437+ await resolveDeviceTokenDecision ( {
438+ verifyBootstrapToken,
439+ verifyDeviceToken,
440+ rateLimiter,
441+ clientIp : "203.0.113.20" ,
442+ stateOverrides : {
443+ bootstrapTokenCandidate : "bootstrap-token" ,
444+ deviceTokenCandidate : undefined ,
445+ deviceTokenCandidateSource : undefined ,
446+ } ,
447+ } ) ,
448+ ) ,
449+ ) ;
450+ const reasons = decisions . map ( ( decision ) => decision . authResult . reason ) ;
451+ expect ( reasons . filter ( ( reason ) => reason === "bootstrap_token_invalid" ) ) . toHaveLength ( 3 ) ;
452+ expect ( reasons . filter ( ( reason ) => reason === "rate_limited" ) ) . toHaveLength ( 5 ) ;
453+ expect ( verifyBootstrapToken ) . toHaveBeenCalledTimes ( 3 ) ;
454+ expect ( maxActiveBootstrapChecks ) . toBe ( 1 ) ;
455+ expect ( verifyDeviceToken ) . not . toHaveBeenCalled ( ) ;
456+ } finally {
457+ rateLimiter . dispose ( ) ;
458+ }
459+ } ) ;
460+
461+ it ( "resets the bootstrap-token bucket when the verify succeeds" , async ( ) => {
462+ const rateLimiter = createPerScopeRateLimiter ( {
463+ "bootstrap-token" : { allowed : true } ,
464+ "device-token" : { allowed : true } ,
465+ "shared-secret" : { allowed : true } ,
466+ } ) ;
467+ const verifyBootstrapToken = vi . fn < VerifyBootstrapTokenFn > ( async ( ) => ( { ok : true } ) ) ;
468+ const verifyDeviceToken = vi . fn < VerifyDeviceTokenFn > ( async ( ) => ( { ok : true } ) ) ;
469+ const decision = await resolveDeviceTokenDecision ( {
470+ verifyBootstrapToken,
471+ verifyDeviceToken,
472+ rateLimiter : rateLimiter . limiter ,
473+ clientIp : "203.0.113.20" ,
474+ stateOverrides : {
475+ bootstrapTokenCandidate : "bootstrap-token" ,
476+ deviceTokenCandidate : undefined ,
477+ deviceTokenCandidateSource : undefined ,
478+ } ,
479+ } ) ;
480+ expect ( decision . authOk ) . toBe ( true ) ;
481+ expect ( decision . authMethod ) . toBe ( "bootstrap-token" ) ;
482+ expect ( rateLimiter . reset ) . toHaveBeenCalledWith ( "203.0.113.20" , "bootstrap-token" ) ;
483+ expect ( rateLimiter . recordFailure ) . not . toHaveBeenCalledWith ( "203.0.113.20" , "bootstrap-token" ) ;
484+ } ) ;
284485} ) ;
0 commit comments