diff --git a/api/tests/unit_tests/.ruff.toml b/api/tests/unit_tests/.ruff.toml index d3394476cc4..7a4db424159 100644 --- a/api/tests/unit_tests/.ruff.toml +++ b/api/tests/unit_tests/.ruff.toml @@ -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"] diff --git a/api/tests/unit_tests/libs/test_api_token_cache.py b/api/tests/unit_tests/libs/test_api_token_cache.py index bdb48ee10e1..0be39376665 100644 --- a/api/tests/unit_tests/libs/test_api_token_cache.py +++ b/api/tests/unit_tests/libs/test_api_token_cache.py @@ -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) diff --git a/api/tests/unit_tests/libs/test_email.py b/api/tests/unit_tests/libs/test_email.py index ae0177791b5..7d19206f6d6 100644 --- a/api/tests/unit_tests/libs/test_email.py +++ b/api/tests/unit_tests/libs/test_email.py @@ -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") diff --git a/api/tests/unit_tests/libs/test_encryption.py b/api/tests/unit_tests/libs/test_encryption.py index bf013c4bae2..e2621a9e3c9 100644 --- a/api/tests/unit_tests/libs/test_encryption.py +++ b/api/tests/unit_tests/libs/test_encryption.py @@ -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" diff --git a/api/tests/unit_tests/libs/test_jwt_imports.py b/api/tests/unit_tests/libs/test_jwt_imports.py index 4acd901b1b6..04c8b039ac9 100644 --- a/api/tests/unit_tests/libs/test_jwt_imports.py +++ b/api/tests/unit_tests/libs/test_jwt_imports.py @@ -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 diff --git a/api/tests/unit_tests/libs/test_oauth_base.py b/api/tests/unit_tests/libs/test_oauth_base.py index 506a54f7ce8..e495e3acc90 100644 --- a/api/tests/unit_tests/libs/test_oauth_base.py +++ b/api/tests/unit_tests/libs/test_oauth_base.py @@ -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") == {} diff --git a/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py b/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py index 053a86cafde..4601e96d616 100644 --- a/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py +++ b/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py @@ -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 diff --git a/api/tests/unit_tests/libs/test_passport.py b/api/tests/unit_tests/libs/test_passport.py index 3adcf7a79fe..cf47a5622d5 100644 --- a/api/tests/unit_tests/libs/test_passport.py +++ b/api/tests/unit_tests/libs/test_passport.py @@ -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: diff --git a/api/tests/unit_tests/libs/test_rate_limit_bearer.py b/api/tests/unit_tests/libs/test_rate_limit_bearer.py index 3d20bff894e..1fc250eaab3 100644 --- a/api/tests/unit_tests/libs/test_rate_limit_bearer.py +++ b/api/tests/unit_tests/libs/test_rate_limit_bearer.py @@ -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", diff --git a/api/tests/unit_tests/libs/test_rsa.py b/api/tests/unit_tests/libs/test_rsa.py index 7063a068ff8..8ce91c945a4 100644 --- a/api/tests/unit_tests/libs/test_rsa.py +++ b/api/tests/unit_tests/libs/test_rsa.py @@ -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") diff --git a/api/tests/unit_tests/libs/test_smtp_client.py b/api/tests/unit_tests/libs/test_smtp_client.py index 481f12b487f..b8a6baaf529 100644 --- a/api/tests/unit_tests/libs/test_smtp_client.py +++ b/api/tests/unit_tests/libs/test_smtp_client.py @@ -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": "Hi"} @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") diff --git a/api/tests/unit_tests/libs/test_token_manager.py b/api/tests/unit_tests/libs/test_token_manager.py index 36c8d4ccd21..fcb30eff4dd 100644 --- a/api/tests/unit_tests/libs/test_token_manager.py +++ b/api/tests/unit_tests/libs/test_token_manager.py @@ -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 diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index a2729db2ddd..54db056fd5a 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -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",