|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect, vi } from 'vitest'; |
4 | | -import { OtpSendGuard, type OtpGuardStorage } from './otp-send-guard.js'; |
| 4 | +import { |
| 5 | + OtpSendGuard, |
| 6 | + MAX_COOLDOWN_SECONDS, |
| 7 | + assertOtpCooldownSeconds, |
| 8 | + type OtpGuardStorage, |
| 9 | +} from './otp-send-guard.js'; |
5 | 10 | import { createLazyCounterStore } from './rate-limit-storage.js'; |
6 | 11 |
|
7 | 12 | const PHONE = '+8613800000000'; |
@@ -228,3 +233,163 @@ describe('OtpSendGuard — where the budget is counted (#4790)', () => { |
228 | 233 | expect((await guard.checkAndRecord(PHONE)).ok).toBe(false); |
229 | 234 | }); |
230 | 235 | }); |
| 236 | + |
| 237 | +// ── #4808 — HOW LONG the history is kept ──────────────────────────────────── |
| 238 | +// |
| 239 | +// The history was pruned (and stored) at a flat one hour — the HOURLY CAP's |
| 240 | +// window, borrowed for the cooldown. A `cooldownSeconds` above 3600 was |
| 241 | +// therefore accepted and then served as one hour, because the record the |
| 242 | +// cooldown measures from had already been dropped. Retention now follows |
| 243 | +// `max(1h, cooldownSeconds)`; beyond MAX_COOLDOWN_SECONDS the config is |
| 244 | +// rejected instead of truncated. |
| 245 | +describe('OtpSendGuard — the cooldown is enforced at its declared length (#4808)', () => { |
| 246 | + /** Store that records the TTL each write asked for. */ |
| 247 | + const makeTtlStore = () => { |
| 248 | + const store = new Map<string, unknown>(); |
| 249 | + const ttls: (number | undefined)[] = []; |
| 250 | + return { |
| 251 | + ttls, |
| 252 | + store, |
| 253 | + get: async (k: string) => (store.has(k) ? store.get(k) : undefined), |
| 254 | + set: async (k: string, v: unknown, ttl?: number) => { |
| 255 | + ttls.push(ttl); |
| 256 | + store.set(k, v); |
| 257 | + }, |
| 258 | + }; |
| 259 | + }; |
| 260 | + |
| 261 | + it('a 2-hour cooldown STILL rejects after the 1-hour mark — the defect itself', async () => { |
| 262 | + const c = clock(); |
| 263 | + const guard = new OtpSendGuard({ cooldownSeconds: 7_200, maxPerHour: 0, now: c.now }); |
| 264 | + |
| 265 | + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); |
| 266 | + |
| 267 | + // 59 minutes in: denied by anyone's reading. |
| 268 | + c.advance(59 * 60_000); |
| 269 | + expect((await guard.checkAndRecord(PHONE)).ok).toBe(false); |
| 270 | + |
| 271 | + // Across the old hard-coded 1h boundary — where the history used to |
| 272 | + // disappear and the "2 hour" cooldown quietly became one hour. |
| 273 | + c.advance(2 * 60_000); // t = 61 min |
| 274 | + const justPastTheHour = await guard.checkAndRecord(PHONE); |
| 275 | + expect(justPastTheHour.ok).toBe(false); |
| 276 | + // …and the retry window is honest about the remaining ~59 minutes. |
| 277 | + expect(justPastTheHour.retryAfterSeconds).toBeGreaterThan(58 * 60); |
| 278 | + expect(justPastTheHour.retryAfterSeconds).toBeLessThanOrEqual(59 * 60); |
| 279 | + |
| 280 | + // Still denied deep into the second hour. |
| 281 | + c.advance(58 * 60_000); // t = 119 min |
| 282 | + expect((await guard.checkAndRecord(PHONE)).ok).toBe(false); |
| 283 | + |
| 284 | + // Only the declared 2 hours frees the number. |
| 285 | + c.advance(2 * 60_000); // t = 121 min |
| 286 | + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); |
| 287 | + }); |
| 288 | + |
| 289 | + it('holds across nodes too — the long cooldown lives in the shared store', async () => { |
| 290 | + const c = clock(); |
| 291 | + const kv = new Map<string, string>(); |
| 292 | + const storage: OtpGuardStorage = { |
| 293 | + get: (k) => kv.get(k) ?? null, |
| 294 | + set: (k, v) => { kv.set(k, v); }, |
| 295 | + }; |
| 296 | + const nodeA = new OtpSendGuard({ cooldownSeconds: 7_200, maxPerHour: 0, storage, now: c.now }); |
| 297 | + const nodeB = new OtpSendGuard({ cooldownSeconds: 7_200, maxPerHour: 0, storage, now: c.now }); |
| 298 | + |
| 299 | + expect((await nodeA.checkAndRecord(PHONE)).ok).toBe(true); |
| 300 | + c.advance(70 * 60_000); // past one hour |
| 301 | + expect((await nodeB.checkAndRecord(PHONE)).ok).toBe(false); |
| 302 | + }); |
| 303 | + |
| 304 | + it('the stored TTL follows the cooldown, so the record outlives what it measures', async () => { |
| 305 | + const c = clock(); |
| 306 | + const long = makeTtlStore(); |
| 307 | + const longGuard = new OtpSendGuard({ |
| 308 | + cooldownSeconds: 7_200, |
| 309 | + now: c.now, |
| 310 | + resolveStore: async () => long as any, |
| 311 | + }); |
| 312 | + await longGuard.checkAndRecord(PHONE); |
| 313 | + expect(long.ttls).toEqual([7_200]); // NOT 3600 — that was the truncation |
| 314 | + |
| 315 | + // A cooldown under an hour keeps the rolling-hour retention the cap needs. |
| 316 | + const short = makeTtlStore(); |
| 317 | + const shortGuard = new OtpSendGuard({ |
| 318 | + cooldownSeconds: 60, |
| 319 | + now: c.now, |
| 320 | + resolveStore: async () => short as any, |
| 321 | + }); |
| 322 | + await shortGuard.checkAndRecord(PHONE); |
| 323 | + expect(short.ttls).toEqual([3_600]); |
| 324 | + }); |
| 325 | + |
| 326 | + it('a long cooldown does not tighten the hourly cap (its window stays one hour)', async () => { |
| 327 | + const c = clock(); |
| 328 | + // Cooldown 90 min, cap 2/hour. The cap must keep counting over its OWN |
| 329 | + // hour: sends are ≥90 min apart, so it must never be the reason for a deny. |
| 330 | + const guard = new OtpSendGuard({ cooldownSeconds: 5_400, maxPerHour: 2, now: c.now }); |
| 331 | + for (let i = 0; i < 4; i++) { |
| 332 | + const d = await guard.checkAndRecord(PHONE); |
| 333 | + expect(d.ok).toBe(true); |
| 334 | + c.advance(91 * 60_000); |
| 335 | + } |
| 336 | + }); |
| 337 | + |
| 338 | + // ── the bound is a rejection, not a higher truncation point ─────────────── |
| 339 | + |
| 340 | + it('rejects a cooldown above the supported maximum instead of truncating it', async () => { |
| 341 | + expect(MAX_COOLDOWN_SECONDS).toBe(86_400); |
| 342 | + expect(() => new OtpSendGuard({ cooldownSeconds: MAX_COOLDOWN_SECONDS + 1 })).toThrow( |
| 343 | + /exceeds the supported maximum of 86400 seconds/, |
| 344 | + ); |
| 345 | + // `cooldownSeconds` handed over in milliseconds (here: "5 minutes"). |
| 346 | + expect(() => new OtpSendGuard({ cooldownSeconds: 300_000 })).toThrow( |
| 347 | + /If the value is in milliseconds, divide by 1000/, |
| 348 | + ); |
| 349 | + // Exactly at the bound is fine. |
| 350 | + expect(() => new OtpSendGuard({ cooldownSeconds: MAX_COOLDOWN_SECONDS })).not.toThrow(); |
| 351 | + }); |
| 352 | + |
| 353 | + it('rejects a cooldown that is not a usable number of seconds', () => { |
| 354 | + for (const bad of [-1, Number.NaN, Number.POSITIVE_INFINITY]) { |
| 355 | + expect(() => assertOtpCooldownSeconds(bad)).toThrow( |
| 356 | + /finite, non-negative number of seconds/, |
| 357 | + ); |
| 358 | + } |
| 359 | + // `undefined` (use the default) and `0` (documented: disables) stay valid. |
| 360 | + expect(() => assertOtpCooldownSeconds(undefined)).not.toThrow(); |
| 361 | + expect(() => assertOtpCooldownSeconds(0)).not.toThrow(); |
| 362 | + }); |
| 363 | + |
| 364 | + // ── acceptance #2: the default path is untouched ────────────────────────── |
| 365 | + |
| 366 | + it('DEFAULT config is unchanged: 60s cooldown, 5 per rolling hour, 1h retention', async () => { |
| 367 | + const c = clock(); |
| 368 | + const store = makeTtlStore(); |
| 369 | + // No cooldownSeconds / maxPerHour at all — exactly what a host that never |
| 370 | + // configures `phoneOtp` gets. |
| 371 | + const guard = new OtpSendGuard({ now: c.now, resolveStore: async () => store as any }); |
| 372 | + |
| 373 | + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); |
| 374 | + const denied = await guard.checkAndRecord(PHONE); |
| 375 | + expect(denied.ok).toBe(false); |
| 376 | + expect(denied.retryAfterSeconds).toBeLessThanOrEqual(60); // the 60s default |
| 377 | + // Retention is still the rolling hour the hourly cap needs. |
| 378 | + expect(store.ttls[0]).toBe(3_600); |
| 379 | + |
| 380 | + // 4 more sends, one per minute → the 5/hour default is reached, not 6. |
| 381 | + for (let i = 0; i < 4; i++) { |
| 382 | + c.advance(61_000); |
| 383 | + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); |
| 384 | + } |
| 385 | + c.advance(61_000); |
| 386 | + const capped = await guard.checkAndRecord(PHONE); |
| 387 | + expect(capped.ok).toBe(false); |
| 388 | + expect(capped.retryAfterSeconds).toBeGreaterThan(60); // the hour, not the cooldown |
| 389 | + |
| 390 | + // An hour after the first send the window rolls and a slot frees up. |
| 391 | + c.advance(3_600_000 - 5 * 61_000); |
| 392 | + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); |
| 393 | + expect(store.ttls.every((t) => t === 3_600)).toBe(true); |
| 394 | + }); |
| 395 | +}); |
0 commit comments