From fb3505a309e6cde22cd0eae4f27bc65c1c236afd Mon Sep 17 00:00:00 2001 From: mavis Date: Sun, 5 Jul 2026 21:24:36 +0000 Subject: [PATCH] feat: extract RateLimiterKeyBuilder.make_key to ratelimit_helpers --- backend/secuscan/rate_limiter.py | 4 +- backend/secuscan/ratelimit_helpers.py | 34 +++++++++++++ .../backend/unit/test_ratelimit_helpers.py | 48 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 backend/secuscan/ratelimit_helpers.py create mode 100644 testing/backend/unit/test_ratelimit_helpers.py diff --git a/backend/secuscan/rate_limiter.py b/backend/secuscan/rate_limiter.py index 4c83dc9b3..4daa466d5 100644 --- a/backend/secuscan/rate_limiter.py +++ b/backend/secuscan/rate_limiter.py @@ -22,6 +22,8 @@ import redis.asyncio as aioredis from fastapi import HTTPException, Request, status +from .ratelimit_helpers import RateLimiterKeyBuilder + logger = logging.getLogger(__name__) @@ -67,7 +69,7 @@ def _get_client_ip(self, request: Request) -> str: def _make_key(self, ip: str, window_type: str, window_value: int) -> str: """Build a namespaced Redis key for this IP and time window.""" - return f"rate_limit:scan:{ip}:{window_type}:{window_value}" + return RateLimiterKeyBuilder.make_key(ip, window_type, window_value) async def check(self, request: Request) -> None: """ diff --git a/backend/secuscan/ratelimit_helpers.py b/backend/secuscan/ratelimit_helpers.py new file mode 100644 index 000000000..7e0a32963 --- /dev/null +++ b/backend/secuscan/ratelimit_helpers.py @@ -0,0 +1,34 @@ +""" +Redis key-builder helpers for ScanRateLimiter. + +Extracted from rate_limiter.py so _make_key can be unit-tested without +pulling in the redis.asyncio / fastapi import chain. + +Public API +---------- +RateLimiterKeyBuilder.make_key(ip: str, window_type: str, window_value: int) -> str + Builds the namespaced Redis key for a per-IP rate-limit bucket. + +Key format: ``rate_limit:scan:{ip}:{window_type}:{window_value}`` +""" + +from __future__ import annotations + + +class RateLimiterKeyBuilder: + """Pure key-formatter for ScanRateLimiter Redis keys.""" + + @staticmethod + def make_key(ip: str, window_type: str, window_value: int) -> str: + """Build a namespaced Redis key for this IP and time window. + + Args: + ip: Client IP address (e.g. "192.168.1.1"). + window_type: Time-window label, typically ``"minute"`` or ``"hour"``. + window_value: Unix timestamp of the window start (seconds). + + Returns: + Redis key string of the form: + ``rate_limit:scan:{ip}:{window_type}:{window_value}`` + """ + return f"rate_limit:scan:{ip}:{window_type}:{window_value}" diff --git a/testing/backend/unit/test_ratelimit_helpers.py b/testing/backend/unit/test_ratelimit_helpers.py new file mode 100644 index 000000000..5c3d3fd72 --- /dev/null +++ b/testing/backend/unit/test_ratelimit_helpers.py @@ -0,0 +1,48 @@ +""" +Unit tests for RateLimiterKeyBuilder.make_key in +backend.secuscan.ratelimit_helpers. + +Tests the key-formatting logic for ScanRateLimiter Redis buckets. +""" + +import pytest +from backend.secuscan.ratelimit_helpers import RateLimiterKeyBuilder + + +class TestRateLimiterKeyBuilderMakeKey: + def test_standard_ipv4_minute_window(self): + key = RateLimiterKeyBuilder.make_key("192.168.1.100", "minute", 1700000000) + assert key == "rate_limit:scan:192.168.1.100:minute:1700000000" + + def test_standard_ipv4_hour_window(self): + key = RateLimiterKeyBuilder.make_key("10.0.0.5", "hour", 1700000000) + assert key == "rate_limit:scan:10.0.0.5:hour:1700000000" + + def test_localhost_minute_window(self): + key = RateLimiterKeyBuilder.make_key("127.0.0.1", "minute", 1700003600) + assert key == "rate_limit:scan:127.0.0.1:minute:1700003600" + + def test_ipv6_address(self): + key = RateLimiterKeyBuilder.make_key("::1", "minute", 1700000000) + assert key == "rate_limit:scan:::1:minute:1700000000" + + def test_window_value_zero(self): + key = RateLimiterKeyBuilder.make_key("1.2.3.4", "minute", 0) + assert key == "rate_limit:scan:1.2.3.4:minute:0" + + def test_window_value_large(self): + key = RateLimiterKeyBuilder.make_key("8.8.8.8", "hour", 9999999999) + assert key == "rate_limit:scan:8.8.8.8:hour:9999999999" + + def test_key_format_exactly_four_components(self): + key = RateLimiterKeyBuilder.make_key("5.5.5.5", "minute", 123) + parts = key.split(":") + assert parts == ["rate_limit", "scan", "5.5.5.5", "minute", "123"] + + def test_empty_ip_produces_valid_key(self): + key = RateLimiterKeyBuilder.make_key("", "minute", 100) + assert key == "rate_limit:scan::minute:100" + + def test_unknown_window_type_produces_valid_key(self): + key = RateLimiterKeyBuilder.make_key("1.1.1.1", "second", 60) + assert key == "rate_limit:scan:1.1.1.1:second:60"