From 29b9e21b7bcb15449d3a7ed9b1b3ec53ff0a493e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:12:24 +0000 Subject: [PATCH] test: simplify auth rate limit coverage --- src/gateway/auth-rate-limit.test.ts | 85 +++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/src/gateway/auth-rate-limit.test.ts b/src/gateway/auth-rate-limit.test.ts index 13ff65eb972..68fa8c14c9d 100644 --- a/src/gateway/auth-rate-limit.test.ts +++ b/src/gateway/auth-rate-limit.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, + AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, createAuthRateLimiter, type AuthRateLimiter, @@ -8,6 +9,23 @@ import { describe("auth rate limiter", () => { let limiter: AuthRateLimiter; + const baseConfig = { maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }; + + function createLimiter( + overrides?: Partial<{ + maxAttempts: number; + windowMs: number; + lockoutMs: number; + exemptLoopback: boolean; + pruneIntervalMs: number; + }>, + ) { + limiter = createAuthRateLimiter({ + ...baseConfig, + ...overrides, + }); + return limiter; + } afterEach(() => { limiter?.dispose(); @@ -32,7 +50,7 @@ describe("auth rate limiter", () => { }); it("blocks the IP once maxAttempts is reached", () => { - limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 10_000 }); + createLimiter({ lockoutMs: 10_000 }); limiter.recordFailure("10.0.0.2"); limiter.recordFailure("10.0.0.2"); const result = limiter.check("10.0.0.2"); @@ -42,12 +60,20 @@ describe("auth rate limiter", () => { expect(result.retryAfterMs).toBeLessThanOrEqual(10_000); }); + it("treats blank scopes as the default scope", () => { + createLimiter(); + limiter.recordFailure("10.0.0.8", " "); + limiter.recordFailure("10.0.0.8"); + expect(limiter.check("10.0.0.8").allowed).toBe(false); + expect(limiter.check("10.0.0.8", " \t ").allowed).toBe(false); + }); + // ---------- lockout expiry ---------- it("unblocks after the lockout period expires", () => { vi.useFakeTimers(); try { - limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 5_000 }); + createLimiter({ lockoutMs: 5_000 }); limiter.recordFailure("10.0.0.3"); limiter.recordFailure("10.0.0.3"); expect(limiter.check("10.0.0.3").allowed).toBe(false); @@ -62,6 +88,25 @@ describe("auth rate limiter", () => { } }); + it("does not extend lockout when failures are recorded while already locked", () => { + vi.useFakeTimers(); + try { + createLimiter({ lockoutMs: 5_000 }); + limiter.recordFailure("10.0.0.33"); + limiter.recordFailure("10.0.0.33"); + const locked = limiter.check("10.0.0.33"); + expect(locked.allowed).toBe(false); + const initialRetryAfter = locked.retryAfterMs; + + vi.advanceTimersByTime(1_000); + limiter.recordFailure("10.0.0.33"); + const afterExtraFailure = limiter.check("10.0.0.33"); + expect(afterExtraFailure.retryAfterMs).toBeLessThanOrEqual(initialRetryAfter - 1_000); + } finally { + vi.useRealTimers(); + } + }); + // ---------- sliding window expiry ---------- it("expires old failures outside the window", () => { @@ -83,7 +128,7 @@ describe("auth rate limiter", () => { // ---------- per-IP isolation ---------- it("tracks IPs independently", () => { - limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }); + createLimiter(); limiter.recordFailure("10.0.0.10"); limiter.recordFailure("10.0.0.10"); expect(limiter.check("10.0.0.10").allowed).toBe(false); @@ -99,26 +144,22 @@ describe("auth rate limiter", () => { expect(limiter.check("::ffff:1.2.3.4").allowed).toBe(false); }); - it("tracks scopes independently for the same IP", () => { - limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); - limiter.recordFailure("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); - expect(limiter.check("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET).allowed).toBe(false); - expect(limiter.check("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN).allowed).toBe(true); - }); + it.each([AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH])( + "tracks %s independently from shared-secret for the same IP", + (otherScope) => { + limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); + expect(limiter.check("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET).allowed).toBe(false); + expect(limiter.check("10.0.0.12", otherScope).allowed).toBe(true); + }, + ); // ---------- loopback exemption ---------- - it("exempts loopback addresses by default", () => { + it.each(["127.0.0.1", "::1"])("exempts loopback address %s by default", (ip) => { limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); - limiter.recordFailure("127.0.0.1"); - // Should still be allowed even though maxAttempts is 1. - expect(limiter.check("127.0.0.1").allowed).toBe(true); - }); - - it("exempts IPv6 loopback by default", () => { - limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); - limiter.recordFailure("::1"); - expect(limiter.check("::1").allowed).toBe(true); + limiter.recordFailure(ip); + expect(limiter.check(ip).allowed).toBe(true); }); it("rate-limits loopback when exemptLoopback is false", () => { @@ -135,7 +176,7 @@ describe("auth rate limiter", () => { // ---------- reset ---------- it("clears tracking state when reset is called", () => { - limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }); + createLimiter(); limiter.recordFailure("10.0.0.20"); limiter.recordFailure("10.0.0.20"); expect(limiter.check("10.0.0.20").allowed).toBe(false); @@ -193,7 +234,7 @@ describe("auth rate limiter", () => { // ---------- undefined / empty IP ---------- it("normalizes undefined IP to 'unknown'", () => { - limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }); + createLimiter(); limiter.recordFailure(undefined); limiter.recordFailure(undefined); expect(limiter.check(undefined).allowed).toBe(false); @@ -201,7 +242,7 @@ describe("auth rate limiter", () => { }); it("normalizes empty-string IP to 'unknown'", () => { - limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }); + createLimiter(); limiter.recordFailure(""); limiter.recordFailure(""); expect(limiter.check("").allowed).toBe(false);