refactor(api): enable strict typing for libs unit tests (#44276)

This commit is contained in:
Eddy ZHANG authored and GitHub committed 2026-10-18 07:06:23 +00:00
1 parent ebb2706139
commit b45a0e89ab
13 files changed
+112 -100

No files matched your search

-2
View File
@@ -251,10 +251,8 @@ extend-select = ["ANN401", "ARG"]
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py" = ["ARG001", "ARG002", "TID251"]
"libs/test_cron_compatibility.py" = ["S110"]
"libs/test_email_i18n.py" = ["ANN401", "TID251"]
"libs/test_oauth_bearer_rate_limit_ordering.py" = ["ARG001"]
"libs/test_schedule_utils_enhanced.py" = ["S110"]
"libs/test_sendgrid_client.py" = ["ARG001", "TID251"]
"libs/test_smtp_client.py" = ["TID251"]
"models/test_dataset_models.py" = ["ARG005"]
"oss/__mock/aliyun_oss.py" = ["ARG002"]
"oss/__mock/baidu_obs.py" = ["ARG002"]
@@ -4,7 +4,7 @@ Unit tests for API Token Cache module.
import json
from datetime import datetime
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from models.enums import ApiTokenType
from models.model import ApiToken
@@ -20,7 +20,7 @@ from services.api_token_service import (
class TestApiTokenCache:
"""Test cases for ApiTokenCache class."""
def setup_method(self):
def setup_method(self) -> None:
"""Setup test fixtures."""
self.mock_token = ApiToken()
self.mock_token.id = "test-token-id-123"
@@ -31,7 +31,7 @@ class TestApiTokenCache:
self.mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
self.mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
def test_make_cache_key(self):
def test_make_cache_key(self) -> None:
"""Test cache key generation."""
# Test with scope
key = ApiTokenCache._make_cache_key("my-token", "app")
@@ -41,7 +41,7 @@ class TestApiTokenCache:
key = ApiTokenCache._make_cache_key("my-token", None)
assert key == f"{CACHE_KEY_PREFIX}:any:my-token"
def test_serialize_token(self):
def test_serialize_token(self) -> None:
"""Test token serialization."""
serialized = ApiTokenCache._serialize_token(self.mock_token)
data = json.loads(serialized)
@@ -54,7 +54,7 @@ class TestApiTokenCache:
assert data["last_used_at"] == "2026-02-03T10:00:00"
assert data["created_at"] == "2026-01-01T00:00:00"
def test_serialize_token_with_nulls(self):
def test_serialize_token_with_nulls(self) -> None:
"""Test token serialization with None values."""
mock_token = ApiToken()
mock_token.id = "test-id"
@@ -72,7 +72,7 @@ class TestApiTokenCache:
assert data["tenant_id"] is None
assert data["last_used_at"] is None
def test_deserialize_token(self):
def test_deserialize_token(self) -> None:
"""Test token deserialization."""
cached_data = json.dumps(
{
@@ -97,18 +97,18 @@ class TestApiTokenCache:
assert result.last_used_at == datetime(2026, 2, 3, 10, 0, 0)
assert result.created_at == datetime(2026, 1, 1, 0, 0, 0)
def test_deserialize_null_token(self):
def test_deserialize_null_token(self) -> None:
"""Test deserialization of null token (cached miss)."""
result = ApiTokenCache._deserialize_token("null")
assert result is None
def test_deserialize_invalid_json(self):
def test_deserialize_invalid_json(self) -> None:
"""Test deserialization with invalid JSON."""
result = ApiTokenCache._deserialize_token("invalid-json{")
assert result is None
@patch("services.api_token_service.redis_client")
def test_get_cache_hit(self, mock_redis):
def test_get_cache_hit(self, mock_redis: MagicMock) -> None:
"""Test cache hit scenario."""
cached_data = json.dumps(
{
@@ -131,7 +131,7 @@ class TestApiTokenCache:
mock_redis.get.assert_called_once_with(f"{CACHE_KEY_PREFIX}:app:test-token")
@patch("services.api_token_service.redis_client")
def test_get_cache_miss(self, mock_redis):
def test_get_cache_miss(self, mock_redis: MagicMock) -> None:
"""Test cache miss scenario."""
mock_redis.get.return_value = None
@@ -141,7 +141,7 @@ class TestApiTokenCache:
mock_redis.get.assert_called_once()
@patch("services.api_token_service.redis_client")
def test_set_valid_token(self, mock_redis):
def test_set_valid_token(self, mock_redis: MagicMock) -> None:
"""Test setting a valid token in cache."""
result = ApiTokenCache.set("test-token", "app", self.mock_token)
@@ -152,7 +152,7 @@ class TestApiTokenCache:
assert args[1] == CACHE_TTL_SECONDS
@patch("services.api_token_service.redis_client")
def test_set_null_token(self, mock_redis):
def test_set_null_token(self, mock_redis: MagicMock) -> None:
"""Test setting a null token (cache penetration prevention)."""
result = ApiTokenCache.set("invalid-token", "app", None)
@@ -164,7 +164,7 @@ class TestApiTokenCache:
assert args[2] == b"null"
@patch("services.api_token_service.redis_client")
def test_delete_with_scope(self, mock_redis):
def test_delete_with_scope(self, mock_redis: MagicMock) -> None:
"""Test deleting token cache with specific scope."""
result = ApiTokenCache.delete("test-token", "app")
@@ -172,7 +172,7 @@ class TestApiTokenCache:
mock_redis.delete.assert_called_once_with(f"{CACHE_KEY_PREFIX}:app:test-token")
@patch("services.api_token_service.redis_client")
def test_delete_without_scope(self, mock_redis):
def test_delete_without_scope(self, mock_redis: MagicMock) -> None:
"""Test deleting token cache without scope (delete all)."""
# Mock scan_iter to return an iterator of keys
mock_redis.scan_iter.return_value = iter(
@@ -197,7 +197,7 @@ class TestApiTokenCache:
)
@patch("services.api_token_service.redis_client")
def test_redis_fallback_on_exception(self, mock_redis):
def test_redis_fallback_on_exception(self, mock_redis: MagicMock) -> None:
"""Test Redis fallback when Redis is unavailable."""
from redis import RedisError
@@ -213,7 +213,7 @@ class TestApiTokenCacheIntegration:
"""Integration test scenarios."""
@patch("services.api_token_service.redis_client")
def test_full_cache_lifecycle(self, mock_redis):
def test_full_cache_lifecycle(self, mock_redis: MagicMock) -> None:
"""Test complete cache lifecycle: set -> get -> delete."""
# Use the persisted model's fields for the cache lifecycle.
mock_token = ApiToken()
@@ -242,7 +242,7 @@ class TestApiTokenCacheIntegration:
assert mock_redis.delete.called
@patch("services.api_token_service.redis_client")
def test_cache_penetration_prevention(self, mock_redis):
def test_cache_penetration_prevention(self, mock_redis: MagicMock) -> None:
"""Test that non-existent tokens are cached as null."""
# Set null token (cache miss)
ApiTokenCache.set("non-existent-token", "app", None)
+2 -2
View File
@@ -3,14 +3,14 @@ import pytest
from libs.helper import email
def test_email_with_valid_email():
def test_email_with_valid_email() -> None:
assert email("test@example.com") == "test@example.com"
assert email("TEST12345@example.com") == "TEST12345@example.com"
assert email("test+test@example.com") == "test+test@example.com"
assert email("!#$%&'*+-/=?^_{|}~`@example.com") == "!#$%&'*+-/=?^_{|}~`@example.com"
def test_email_with_invalid_email():
def test_email_with_invalid_email() -> None:
with pytest.raises(ValueError, match="invalid_email is not a valid email."):
email("invalid_email")
+14 -14
View File
@@ -13,7 +13,7 @@ from libs.encryption import FieldEncryption
class TestDecodeField:
"""Test cases for field decoding functionality."""
def test_decode_valid_base64(self):
def test_decode_valid_base64(self) -> None:
"""Test decoding a valid Base64 encoded string."""
plaintext = "password123"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
@@ -21,14 +21,14 @@ class TestDecodeField:
result = FieldEncryption.decrypt_field(encoded)
assert result == plaintext
def test_decode_non_base64_returns_none(self):
def test_decode_non_base64_returns_none(self) -> None:
"""Test that non-base64 input returns None."""
non_base64 = "plain-password-!@#"
result = FieldEncryption.decrypt_field(non_base64)
# Should return None (decoding failed)
assert result is None
def test_decode_unicode_text(self):
def test_decode_unicode_text(self) -> None:
"""Test decoding Base64 encoded Unicode text."""
plaintext = "密码Test123"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
@@ -36,13 +36,13 @@ class TestDecodeField:
result = FieldEncryption.decrypt_field(encoded)
assert result == plaintext
def test_decode_empty_string(self):
def test_decode_empty_string(self) -> None:
"""Test decoding an empty string returns empty string."""
result = FieldEncryption.decrypt_field("")
# Empty string base64 decodes to empty string
assert result == ""
def test_decode_special_characters(self):
def test_decode_special_characters(self) -> None:
"""Test decoding with special characters."""
plaintext = "P@ssw0rd!#$%^&*()"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
@@ -54,7 +54,7 @@ class TestDecodeField:
class TestDecodePassword:
"""Test cases for password decoding."""
def test_decode_password_base64(self):
def test_decode_password_base64(self) -> None:
"""Test decoding a Base64 encoded password."""
password = "SecureP@ssw0rd!"
encoded = base64.b64encode(password.encode("utf-8")).decode()
@@ -62,7 +62,7 @@ class TestDecodePassword:
result = FieldEncryption.decrypt_password(encoded)
assert result == password
def test_decode_password_invalid_returns_none(self):
def test_decode_password_invalid_returns_none(self) -> None:
"""Test that invalid base64 passwords return None."""
invalid = "PlainPassword!@#"
result = FieldEncryption.decrypt_password(invalid)
@@ -73,7 +73,7 @@ class TestDecodePassword:
class TestDecodeVerificationCode:
"""Test cases for verification code decoding."""
def test_decode_code_base64(self):
def test_decode_code_base64(self) -> None:
"""Test decoding a Base64 encoded verification code."""
code = "789012"
encoded = base64.b64encode(code.encode("utf-8")).decode()
@@ -81,7 +81,7 @@ class TestDecodeVerificationCode:
result = FieldEncryption.decrypt_verification_code(encoded)
assert result == code
def test_decode_code_invalid_returns_none(self):
def test_decode_code_invalid_returns_none(self) -> None:
"""Test that invalid base64 codes return None."""
invalid = "123456" # Plain 6-digit code, not base64
result = FieldEncryption.decrypt_verification_code(invalid)
@@ -95,7 +95,7 @@ class TestRoundTripEncodingDecoding:
These tests simulate the full frontend-to-backend flow using Base64.
"""
def test_roundtrip_password(self):
def test_roundtrip_password(self) -> None:
"""Test encoding and decoding a password."""
original_password = "SecureP@ssw0rd!"
@@ -107,7 +107,7 @@ class TestRoundTripEncodingDecoding:
assert decoded == original_password
def test_roundtrip_verification_code(self):
def test_roundtrip_verification_code(self) -> None:
"""Test encoding and decoding a verification code."""
original_code = "123456"
@@ -119,7 +119,7 @@ class TestRoundTripEncodingDecoding:
assert decoded == original_code
def test_roundtrip_unicode_password(self):
def test_roundtrip_unicode_password(self) -> None:
"""Test encoding and decoding password with Unicode characters."""
original_password = "密码Test123!@#"
@@ -131,7 +131,7 @@ class TestRoundTripEncodingDecoding:
assert decoded == original_password
def test_roundtrip_long_password(self):
def test_roundtrip_long_password(self) -> None:
"""Test encoding and decoding a long password."""
original_password = "ThisIsAVeryLongPasswordWithLotsOfCharacters123!@#$%^&*()"
@@ -140,7 +140,7 @@ class TestRoundTripEncodingDecoding:
assert decoded == original_password
def test_roundtrip_with_whitespace(self):
def test_roundtrip_with_whitespace(self) -> None:
"""Test encoding and decoding with whitespace."""
original_password = "pass word with spaces"
@@ -6,7 +6,7 @@ import pytest
class TestPyJWTImports:
"""Test PyJWT import paths used throughout the codebase."""
def test_invalid_token_error_import(self):
def test_invalid_token_error_import(self) -> None:
"""Test that InvalidTokenError can be imported as used in login controller."""
# This test verifies the import path used in controllers/web/login.py:2
# If PyJWT changes this import path, this test will fail early
@@ -23,7 +23,7 @@ class TestPyJWTImports:
except ImportError as e:
pytest.fail(f"Failed to import InvalidTokenError from jwt: {e}")
def test_jwt_exceptions_import(self):
def test_jwt_exceptions_import(self) -> None:
"""Test that jwt.exceptions imports work as expected."""
# Alternative import path that might be used
try:
@@ -36,7 +36,7 @@ class TestPyJWTImports:
except ImportError as e:
pytest.fail(f"Failed to import InvalidTokenError from jwt.exceptions: {e}")
def test_other_jwt_exceptions_available(self):
def test_other_jwt_exceptions_available(self) -> None:
"""Test that other common JWT exceptions are available."""
# Test other exceptions that might be used in the codebase
try:
@@ -50,7 +50,7 @@ class TestPyJWTImports:
except ImportError as e:
pytest.fail(f"Failed to import JWT exceptions: {e}")
def test_jwt_main_functions_available(self):
def test_jwt_main_functions_available(self) -> None:
"""Test that main JWT functions are available."""
try:
from jwt import decode, encode
+3 -3
View File
@@ -3,7 +3,7 @@ import pytest
from libs.oauth import OAuth, decode_oauth_state, encode_oauth_state
def test_oauth_base_methods_raise_not_implemented():
def test_oauth_base_methods_raise_not_implemented() -> None:
oauth = OAuth(client_id="id", client_secret="sec", redirect_uri="uri")
with pytest.raises(NotImplementedError):
@@ -19,7 +19,7 @@ def test_oauth_base_methods_raise_not_implemented():
oauth._transform_user_info({})
def test_oauth_state_round_trips_login_context():
def test_oauth_state_round_trips_login_context() -> None:
state = encode_oauth_state(
invite_token="invite-123",
timezone="Asia/Shanghai",
@@ -35,5 +35,5 @@ def test_oauth_state_round_trips_login_context():
}
def test_oauth_state_returns_empty_payload_for_invalid_state():
def test_oauth_state_returns_empty_payload_for_invalid_state() -> None:
assert decode_oauth_state("invalid-state") == {}
@@ -1,6 +1,6 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from redis import Redis
@@ -15,7 +15,7 @@ def _authenticator_with(resolver: Resolver) -> BearerAuthenticator:
@patch("libs.oauth_bearer.enforce_bearer_rate_limit")
def test_rate_limit_called_before_resolve(rl, monkeypatch: pytest.MonkeyPatch):
def test_rate_limit_called_before_resolve(rl: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
call_order: list[str] = []
rl.side_effect = lambda _h: call_order.append("rl")
resolver = OAuthAccessTokenResolver(sessionmaker(), Redis())
@@ -28,12 +28,20 @@ def test_rate_limit_called_before_resolve(rl, monkeypatch: pytest.MonkeyPatch):
assert call_order == ["rl", "resolve"], f"expected rl before resolve, got {call_order}"
@pytest.mark.parametrize("token", ["zzz_xyz", "dfoa_revoked"], ids=["unknown prefix", "revoked"])
# An unknown prefix is refused before the rate limit; a known prefix is rate limited, then refused.
@pytest.mark.parametrize(
("token", "rate_limited"),
[("zzz_xyz", False), ("dfoa_revoked", True)],
ids=["unknown prefix", "revoked"],
)
@patch("libs.oauth_bearer.enforce_bearer_rate_limit")
def test_every_refusal_raises_the_generic_invalid_bearer(rl, token: str, monkeypatch: pytest.MonkeyPatch):
def test_every_refusal_raises_the_generic_invalid_bearer(
rl: MagicMock, token: str, rate_limited: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
resolver = OAuthAccessTokenResolver(sessionmaker(), Redis())
monkeypatch.setattr(resolver, "cache_get", lambda _h: "invalid")
auth = _authenticator_with(resolver.for_token_type(TokenType.OAUTH_ACCOUNT))
with pytest.raises(InvalidBearerError) as exc:
auth.authenticate(token)
assert str(exc.value) == "invalid_bearer"
assert rl.called is rate_limited
+20 -15
View File
@@ -1,3 +1,4 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
@@ -12,19 +13,19 @@ class TestPassportService:
"""Test PassportService JWT operations"""
@pytest.fixture
def passport_service(self, config_overrides):
def passport_service(self, config_overrides: Callable[..., None]) -> PassportService:
"""Create PassportService instance with test secret key"""
config_overrides(SECRET_KEY="test-secret-key-for-testing")
return PassportService()
@pytest.fixture
def another_passport_service(self, config_overrides):
def another_passport_service(self, config_overrides: Callable[..., None]) -> PassportService:
"""Create another PassportService instance with different secret key"""
config_overrides(SECRET_KEY="another-secret-key-for-testing")
return PassportService()
# Core functionality tests
def test_should_issue_and_verify_token(self, passport_service):
def test_should_issue_and_verify_token(self, passport_service: PassportService) -> None:
"""Test complete JWT lifecycle: issue and verify"""
payload = {"user_id": "123", "app_code": "test-app"}
token = passport_service.issue(payload)
@@ -37,9 +38,9 @@ class TestPassportService:
decoded = passport_service.verify(token)
assert decoded == payload
def test_should_handle_different_payload_types(self, passport_service):
def test_should_handle_different_payload_types(self, passport_service: PassportService) -> None:
"""Test issuing and verifying tokens with different payload types"""
test_cases = [
test_cases: list[dict[str, object]] = [
{"string": "value"},
{"number": 42},
{"float": float("3.14")},
@@ -58,7 +59,7 @@ class TestPassportService:
assert decoded == payload
# Security tests
def test_should_reject_modified_token(self, passport_service):
def test_should_reject_modified_token(self, passport_service: PassportService) -> None:
"""Test that any modification to token invalidates it"""
token = passport_service.issue({"user": "test"})
@@ -72,7 +73,9 @@ class TestPassportService:
with pytest.raises(Unauthorized):
passport_service.verify(tampered)
def test_should_reject_token_with_different_secret_key(self, passport_service, another_passport_service):
def test_should_reject_token_with_different_secret_key(
self, passport_service: PassportService, another_passport_service: PassportService
) -> None:
"""Test key isolation - token from one service should not work with another"""
payload = {"user_id": "123", "app_code": "test-app"}
token = passport_service.issue(payload)
@@ -81,7 +84,7 @@ class TestPassportService:
another_passport_service.verify(token)
assert str(exc_info.value) == "401 Unauthorized: Invalid token signature."
def test_should_use_hs256_algorithm(self, passport_service):
def test_should_use_hs256_algorithm(self, passport_service: PassportService) -> None:
"""Test that HS256 algorithm is used for signing"""
payload = {"test": "data"}
token = passport_service.issue(payload)
@@ -91,7 +94,7 @@ class TestPassportService:
header = jwt.get_unverified_header(token)
assert header["alg"] == "HS256"
def test_should_reject_token_with_wrong_algorithm(self, passport_service):
def test_should_reject_token_with_wrong_algorithm(self, passport_service: PassportService) -> None:
"""Test rejection of token signed with different algorithm"""
payload = {"user_id": "123"}
@@ -106,7 +109,7 @@ class TestPassportService:
assert str(exc_info.value) == "401 Unauthorized: Invalid token."
# Exception handling tests
def test_should_handle_invalid_tokens(self, passport_service):
def test_should_handle_invalid_tokens(self, passport_service: PassportService) -> None:
"""Test handling of various invalid token formats"""
invalid_tokens = [
("not.a.token", "Invalid token."),
@@ -125,7 +128,7 @@ class TestPassportService:
passport_service.verify(invalid_token)
assert expected_message in str(exc_info.value)
def test_should_reject_expired_token(self, passport_service):
def test_should_reject_expired_token(self, passport_service: PassportService) -> None:
"""Test rejection of expired token"""
past_time = datetime.now(UTC) - timedelta(hours=1)
payload = {"user_id": "123", "exp": past_time.timestamp()}
@@ -137,7 +140,9 @@ class TestPassportService:
assert str(exc_info.value) == "401 Unauthorized: Token has expired."
# Configuration tests
def test_should_use_configured_secret_key_without_policy_validation(self, config_overrides):
def test_should_use_configured_secret_key_without_policy_validation(
self, config_overrides: Callable[..., None]
) -> None:
"""Test that policy decisions are owned by config, not PassportService."""
config_overrides(SECRET_KEY="configured")
service = PassportService()
@@ -145,7 +150,7 @@ class TestPassportService:
assert service.sk == "configured"
# Boundary condition tests
def test_should_handle_large_payload(self, passport_service):
def test_should_handle_large_payload(self, passport_service: PassportService) -> None:
"""Test handling of large payload"""
# Test with 100KB instead of 1MB for faster tests
large_data = "x" * (100 * 1024)
@@ -156,7 +161,7 @@ class TestPassportService:
assert decoded["data"] == large_data
def test_should_handle_special_characters_in_payload(self, passport_service):
def test_should_handle_special_characters_in_payload(self, passport_service: PassportService) -> None:
"""Test handling of special characters in payload"""
special_payloads = [
{"special": "!@#$%^&*()"},
@@ -172,7 +177,7 @@ class TestPassportService:
decoded = passport_service.verify(token)
assert decoded == payload
def test_should_catch_generic_pyjwt_errors(self, passport_service):
def test_should_catch_generic_pyjwt_errors(self, passport_service: PassportService) -> None:
"""Test that generic PyJWTError exceptions are caught and converted to Unauthorized"""
# Mock jwt.decode to raise a generic PyJWTError
with patch("libs.passport.jwt.decode") as mock_decode:
@@ -3,32 +3,37 @@
from __future__ import annotations
from datetime import timedelta
from unittest.mock import patch
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from redis import Redis
from werkzeug.exceptions import TooManyRequests
from libs.helper import RateLimiter
from libs.helper import RateLimiter, _RateLimiterRedisClient
from libs.rate_limit import (
LIMIT_BEARER_PER_TOKEN,
RateLimit,
RateLimitScope,
_BearerRateLimited,
enforce_bearer_rate_limit,
)
@pytest.fixture
def redis_client():
return Redis()
def redis_client() -> _RateLimiterRedisClient:
# A real client whose execute_command each test replaces. RateLimiter's protocol follows dify's
# RedisClientWrapper, whose zadd accepts a wider mapping than redis-py's stubs.
return cast(_RateLimiterRedisClient, Redis())
def test_limit_bearer_per_token_uses_60_per_minute_default():
def test_limit_bearer_per_token_uses_60_per_minute_default() -> None:
assert LIMIT_BEARER_PER_TOKEN.limit == 60
assert LIMIT_BEARER_PER_TOKEN.window == timedelta(minutes=1)
def test_seconds_until_available_returns_remaining_window(redis_client, monkeypatch: pytest.MonkeyPatch):
def test_seconds_until_available_returns_remaining_window(
redis_client: _RateLimiterRedisClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ZSET oldest entry score = 100; window = 60s; now = 130s → remaining = 30s."""
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=redis_client)
monkeypatch.setattr(redis_client, "execute_command", lambda *_args, **_kwargs: [(b"member-1", 100.0)])
@@ -36,7 +41,9 @@ def test_seconds_until_available_returns_remaining_window(redis_client, monkeypa
assert rl.seconds_until_available("k1") == 30
def test_seconds_until_available_floor_one_second(redis_client, monkeypatch: pytest.MonkeyPatch):
def test_seconds_until_available_floor_one_second(
redis_client: _RateLimiterRedisClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Even when math says <1s remaining, return at least 1 so client backs off measurably."""
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=redis_client)
monkeypatch.setattr(redis_client, "execute_command", lambda *_args, **_kwargs: [(b"member-1", 119.5)])
@@ -46,7 +53,9 @@ def test_seconds_until_available_floor_one_second(redis_client, monkeypatch: pyt
assert rl.seconds_until_available("k1") >= 1
def test_seconds_until_available_empty_bucket(redis_client, monkeypatch: pytest.MonkeyPatch):
def test_seconds_until_available_empty_bucket(
redis_client: _RateLimiterRedisClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No entries → 1s sentinel (defensive; should not be reached when limited)."""
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=redis_client)
monkeypatch.setattr(redis_client, "execute_command", lambda *_args, **_kwargs: [])
@@ -54,10 +63,12 @@ def test_seconds_until_available_empty_bucket(redis_client, monkeypatch: pytest.
@patch("libs.rate_limit._build_limiter")
def test_enforce_bearer_rate_limit_passes_under_limit(mock_build, redis_client, monkeypatch: pytest.MonkeyPatch):
commands: list[tuple] = []
def test_enforce_bearer_rate_limit_passes_under_limit(
mock_build: MagicMock, redis_client: _RateLimiterRedisClient, monkeypatch: pytest.MonkeyPatch
) -> None:
commands: list[tuple[object, ...]] = []
def execute_command(*args, **_kwargs):
def execute_command(*args: object, **_kwargs: object) -> int:
commands.append(args)
return 0
@@ -78,11 +89,11 @@ def test_enforce_bearer_rate_limit_passes_under_limit(mock_build, redis_client,
@patch("libs.rate_limit._build_limiter")
def test_enforce_bearer_rate_limit_raises_429_with_retry_after(
mock_build, redis_client, monkeypatch: pytest.MonkeyPatch
):
commands: list[tuple] = []
mock_build: MagicMock, redis_client: _RateLimiterRedisClient, monkeypatch: pytest.MonkeyPatch
) -> None:
commands: list[tuple[object, ...]] = []
def execute_command(*args, **_kwargs):
def execute_command(*args: object, **_kwargs: object) -> list[tuple[bytes, float]] | int:
commands.append(args)
if args[0] == "ZRANGE":
return [(b"member-1", 63.0)]
@@ -91,7 +102,7 @@ def test_enforce_bearer_rate_limit_raises_429_with_retry_after(
monkeypatch.setattr(redis_client, "execute_command", execute_command)
limiter = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=redis_client)
mock_build.return_value = limiter
with patch("libs.helper.time.time", return_value=100), pytest.raises(TooManyRequests) as exc:
with patch("libs.helper.time.time", return_value=100), pytest.raises(_BearerRateLimited) as exc:
enforce_bearer_rate_limit("hash-1")
assert [command[0] for command in commands] == ["ZREMRANGEBYSCORE", "ZCARD", "ZRANGE"]
# Header-only TooManyRequests: the canonical ErrorBody (code "too_many_requests") is built
@@ -100,7 +111,9 @@ def test_enforce_bearer_rate_limit_raises_429_with_retry_after(
@patch("libs.rate_limit._build_limiter")
def test_enforce_bearer_rate_limit_disabled_when_limit_is_zero(mock_build, monkeypatch: pytest.MonkeyPatch):
def test_enforce_bearer_rate_limit_disabled_when_limit_is_zero(
mock_build: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
# 0 disables the limit — short-circuit before building/consulting a limiter.
monkeypatch.setattr(
"libs.rate_limit.LIMIT_BEARER_PER_TOKEN",
+1 -1
View File
@@ -3,7 +3,7 @@ from Crypto.PublicKey import RSA
from libs import gmpy2_pkcs10aep_cipher
def test_gmpy2_pkcs10aep_cipher():
def test_gmpy2_pkcs10aep_cipher() -> None:
rsa_key = RSA.generate(2048)
public_key = rsa_key.publickey().export_key(format="PEM")
private_key = rsa_key.export_key(format="PEM")
@@ -1,5 +1,4 @@
import smtplib
from typing import Any
from unittest.mock import ANY, MagicMock, patch
import pytest
@@ -7,12 +6,12 @@ import pytest
from libs.smtp import SMTPClient
def _mail() -> dict[str, Any]:
def _mail() -> dict[str, str]:
return {"to": "user@example.com", "subject": "Hi", "html": "<b>Hi</b>"}
@patch("libs.smtp.smtplib.SMTP", autospec=True)
def test_smtp_plain_success(mock_smtp_cls: MagicMock):
def test_smtp_plain_success(mock_smtp_cls: MagicMock) -> None:
mock_smtp = mock_smtp_cls.return_value
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", _from="noreply@example.com")
client.send(_mail())
@@ -23,7 +22,7 @@ def test_smtp_plain_success(mock_smtp_cls: MagicMock):
@patch("libs.smtp.smtplib.SMTP", autospec=True)
def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock):
def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock) -> None:
mock_smtp = mock_smtp_cls.return_value
client = SMTPClient(
server="smtp.example.com",
@@ -45,7 +44,7 @@ def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock):
@patch("libs.smtp.smtplib.SMTP_SSL", autospec=True)
def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock):
def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock) -> None:
# Cover SMTP_SSL branch and TimeoutError handling
mock_smtp = MagicMock()
mock_smtp.sendmail.side_effect = TimeoutError("timeout")
@@ -66,7 +65,7 @@ def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock):
@patch("libs.smtp.smtplib.SMTP", autospec=True)
def test_smtp_generic_exception_propagates(mock_smtp_cls: MagicMock):
def test_smtp_generic_exception_propagates(mock_smtp_cls: MagicMock) -> None:
mock_smtp = MagicMock()
mock_smtp.sendmail.side_effect = RuntimeError("oops")
mock_smtp_cls.return_value = mock_smtp
@@ -78,7 +77,7 @@ def test_smtp_generic_exception_propagates(mock_smtp_cls: MagicMock):
@patch("libs.smtp.smtplib.SMTP", autospec=True)
def test_smtp_smtplib_exception_in_login(mock_smtp_cls: MagicMock):
def test_smtp_smtplib_exception_in_login(mock_smtp_cls: MagicMock) -> None:
# Ensure we hit the specific SMTPException except branch
import smtplib
@@ -114,7 +113,7 @@ def test_smtp_cleanup_preserves_send_result(
send_error_type: type[Exception] | None,
close_fails: bool,
caplog: pytest.LogCaptureFixture,
):
) -> None:
send_error: Exception | None = None
if send_error_type is smtplib.SMTPDataError:
send_error = smtplib.SMTPDataError(451, b"temporary send failure")
@@ -17,7 +17,7 @@ import libs.helper as helper_module
from libs.helper import TokenManager
def _build_fake_redis(storage: dict[str, str]):
def _build_fake_redis(storage: dict[str, str]) -> SimpleNamespace:
def store_value(key: str, _ttl: int, value: str) -> bool:
storage[key] = value
return True
-11
View File
@@ -483,38 +483,27 @@ project-excludes = [
"libs/_human_input/test_models.py",
"libs/broadcast_channel/redis/test_channel_unit_tests.py",
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py",
"libs/test_api_token_cache.py",
"libs/test_archive_storage.py",
"libs/test_cron_compatibility.py",
"libs/test_custom_inputs.py",
"libs/test_datetime_utils.py",
"libs/test_email.py",
"libs/test_email_i18n.py",
"libs/test_encryption.py",
"libs/test_external_api.py",
"libs/test_file_utils.py",
"libs/test_flask_utils.py",
"libs/test_helper.py",
"libs/test_json_in_md_parser.py",
"libs/test_jwt_imports.py",
"libs/test_login.py",
"libs/test_oauth_base.py",
"libs/test_oauth_bearer.py",
"libs/test_oauth_bearer_rate_limit_ordering.py",
"libs/test_orjson.py",
"libs/test_pagination.py",
"libs/test_pandas.py",
"libs/test_passport.py",
"libs/test_password.py",
"libs/test_rate_limit_bearer.py",
"libs/test_rate_limiter.py",
"libs/test_rsa.py",
"libs/test_schedule_utils_enhanced.py",
"libs/test_sendgrid_client.py",
"libs/test_smtp_client.py",
"libs/test_time_parser.py",
"libs/test_token.py",
"libs/test_token_manager.py",
"libs/test_uuid_utils.py",
"libs/test_yarl.py",
"migrations/test_uuidv7_pg18_migration.py",