@@ -1734,6 +1734,88 @@ describe('AuthManager', () => {
17341734 await manager . assertPhoneOtpSendAllowed ( PHONE ) ;
17351735 } ) ;
17361736
1737+ // ── #4790 — the budget is only global if its STORE is ─────────────────
1738+ describe ( 'where the per-number budget is counted (#4790)' , ( ) => {
1739+ const makeCache = ( ) => {
1740+ const store = new Map < string , unknown > ( ) ;
1741+ return {
1742+ store,
1743+ get : vi . fn ( async ( k : string ) => ( store . has ( k ) ? store . get ( k ) : undefined ) ) ,
1744+ set : vi . fn ( async ( k : string , v : unknown , _ttl ?: number ) => { store . set ( k , v ) ; } ) ,
1745+ } ;
1746+ } ;
1747+ const bootNode = async ( sharedCounterStore : any ) => {
1748+ const { manager } = await bootOtp ( { sharedCounterStore } ) ;
1749+ manager . setSmsService ( fakeSms ( ) . service ) ;
1750+ return manager ;
1751+ } ;
1752+
1753+ it ( 'spends ONE budget across nodes when a shared counter store is wired' , async ( ) => {
1754+ const { createLazyCounterStore } = await import ( './rate-limit-storage.js' ) ;
1755+ const cache = makeCache ( ) ;
1756+ // Two nodes: separate managers, separate resolvers, one cache.
1757+ const nodeStore = ( ) =>
1758+ createLazyCounterStore ( { resolveCache : async ( ) => cache as any , subject : 'otp-budget' } ) ;
1759+ const nodeA = await bootNode ( nodeStore ( ) ) ;
1760+ const nodeB = await bootNode ( nodeStore ( ) ) ;
1761+
1762+ await nodeA . assertPhoneOtpSendAllowed ( PHONE ) ;
1763+ // Rotating nodes no longer buys a fresh cooldown.
1764+ await expect ( nodeB . assertPhoneOtpSendAllowed ( PHONE ) )
1765+ . rejects . toThrow ( / T o o m a n y v e r i f i c a t i o n c o d e s / ) ;
1766+ expect ( cache . store . size ) . toBe ( 1 ) ;
1767+ expect ( [ ...cache . store . keys ( ) ] [ 0 ] ) . toContain ( PHONE ) ;
1768+ } ) ;
1769+
1770+ it ( 'resolves the store per check, so a cache registered after boot still binds' , async ( ) => {
1771+ const { createLazyCounterStore } = await import ( './rate-limit-storage.js' ) ;
1772+ const cache = makeCache ( ) ;
1773+ let registered = false ;
1774+ const manager = await bootNode (
1775+ createLazyCounterStore ( {
1776+ resolveCache : async ( ) => ( registered ? ( cache as any ) : undefined ) ,
1777+ subject : 'otp-budget' ,
1778+ } ) ,
1779+ ) ;
1780+ registered = true ; // CacheServicePlugin comes up after plugin-auth.
1781+ await manager . assertPhoneOtpSendAllowed ( PHONE ) ;
1782+ expect ( cache . store . size ) . toBe ( 1 ) ;
1783+ } ) ;
1784+
1785+ it ( 'a host-supplied secondaryStorage keeps owning the budget' , async ( ) => {
1786+ const kv = new Map < string , string > ( ) ;
1787+ const secondaryStorage = {
1788+ get : async ( k : string ) => kv . get ( k ) ?? null ,
1789+ set : async ( k : string , v : string ) => { kv . set ( k , v ) ; } ,
1790+ delete : async ( k : string ) => { kv . delete ( k ) ; } ,
1791+ } ;
1792+ const cache = makeCache ( ) ;
1793+ const { manager } = await bootOtp ( {
1794+ secondaryStorage,
1795+ sharedCounterStore : async ( ) => cache as any ,
1796+ } ) ;
1797+ manager . setSmsService ( fakeSms ( ) . service ) ;
1798+ await manager . assertPhoneOtpSendAllowed ( PHONE ) ;
1799+ expect ( kv . size ) . toBe ( 1 ) ;
1800+ expect ( cache . store . size ) . toBe ( 0 ) ;
1801+ } ) ;
1802+
1803+ it ( 'without any shared store the budget is per-manager — degraded, still enforced' , async ( ) => {
1804+ const { manager : nodeA } = await bootOtp ( ) ;
1805+ const { manager : nodeB } = await bootOtp ( ) ;
1806+ nodeA . setSmsService ( fakeSms ( ) . service ) ;
1807+ nodeB . setSmsService ( fakeSms ( ) . service ) ;
1808+
1809+ await nodeA . assertPhoneOtpSendAllowed ( PHONE ) ;
1810+ // Enforced on its own node…
1811+ await expect ( nodeA . assertPhoneOtpSendAllowed ( PHONE ) )
1812+ . rejects . toThrow ( / T o o m a n y v e r i f i c a t i o n c o d e s / ) ;
1813+ // …and not on the other one: exactly the N× multiplication #4790 is
1814+ // about, which is why AuthPlugin warns loudly when it has to do this.
1815+ await nodeB . assertPhoneOtpSendAllowed ( PHONE ) ;
1816+ } ) ;
1817+ } ) ;
1818+
17371819 it ( 'features.phoneNumberOtp requires plugin + deliverable SMS' , async ( ) => {
17381820 const { manager } = await bootOtp ( ) ;
17391821 expect ( ( manager . getPublicConfig ( ) as any ) . features . phoneNumberOtp ) . toBe ( false ) ;
0 commit comments