mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 14:21:29 +08:00
refactor(api): enable strict typing for libs unit tests (#44276)
This commit is contained in:
1 parent
ebb2706139
commit
b45a0e89ab
13 files changed
+112
-100
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user