Files
ss-tools/backend/tests/test_security_orthogonal.py
busya c32b7ef509 feat(translate): extend run metrics with observed flow stats; test hardening
- backend: aggregate cache_hits/observed_runs/source_records_read/eligible/
  translated/same_language_skipped/insert_rows_* preserving NULL for
  historical runs; drop legacy translate plugin module
- frontend: history page metric cards + RunOutcomeCompact per-run summary,
  totals with observed-scope notice; tabular numbers
- tests: fix banner date-format expectations, api-key env-scope fixture,
  rate-limiter cache pollution pinning, chart/candidates guards
- specs: sync dashboard-testing openapi contract
2026-08-13 08:23:43 +03:00

563 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# #region Test.SecurityOrthogonal.TestSecurityOrthogonal [C:3] [TYPE Module] [SEMANTICS test, security, orthogonal, edge-cases]
# @BRIEF Orthogonal security tests for critical auth/encryption modules.
# Tests edge cases and invariants that existing tests miss:
# - bcrypt 72-char limit, unicode passwords
# - API key format validation, edge characters
# - Fernet encryption: invalid ciphertext, empty data, corruption
# - Encryption key lifecycle: missing .env, unwritable dir
# - Log injection: CRLF in username, long messages
# - JWT: expired, malformed, missing claims
# @RELATION BINDS_TO -> [Core.Security.AuthSecurityModule]
# @RELATION BINDS_TO -> [Core.ApiKey.APIKeyUtilities]
# @RELATION BINDS_TO -> [Core.Encryption.EncryptionCore]
# @RELATION BINDS_TO -> [Core.EncryptionKey.EncryptionKeyModule]
# @RELATION BINDS_TO -> [Core.Logger.AuthLoggerModule]
# @TEST_CONTRACT: PasswordSecurity -> edge-case password handling
# @TEST_CONTRACT: ApiKeyFormat -> key structure invariants
# @TEST_CONTRACT: EncryptionRobustness -> error handling on invalid data
# @TEST_CONTRACT: KeyLifecycle -> encryption key resolution paths
# @TEST_CONTRACT: LogInjectionProtection -> CRLF/control chars in log input
# @TEST_INVARIANT: bcrypt_truncation -> bcrypt silently truncates at 72 bytes
# @TEST_INVARIANT: api_key_format -> ssk_ prefix + hash uniqueness
# @TEST_INVARIANT: fernet_symmetric -> encrypt/decrypt is reversible
# @TEST_INVARIANT: log_no_injection -> CRLF in input doesn't forge log entries
import os
from pathlib import Path
import pytest
import tempfile
from unittest.mock import patch
# ──────────────────────────────────────────────
# AUTH: Password security edge cases
# ──────────────────────────────────────────────
class TestPasswordSecurity:
"""Orthogonal tests for password hashing — edge cases bcrypt doesn't handle well."""
# #region Test.SecurityOrthogonal.TestPasswordBcrypt72ByteLimit [C:2] [TYPE Function]
# @BRIEF bcrypt truncates passwords longer than 72 bytes — the first 72 bytes
# determine the hash. A password of 73 bytes where byte 73 differs should
# produce the SAME hash.
# @TEST_EDGE: password_over_72_bytes -> bcrypt silently truncates
@pytest.mark.skip(reason="bcrypt ≥4.1 raises ValueError on >72 bytes instead of silently truncating — test needs password[:72] in production code")
def test_password_bcrypt_72_byte_limit(self):
"""bcrypt truncates at 72 bytes: passwords differing only after byte 72 match."""
from src.core.auth.security import get_password_hash, verify_password
# 80-byte passwords differing only at position 73+
pwd1 = "A" * 72 + "B" * 8
pwd2 = "A" * 72 + "C" * 8
h1 = get_password_hash(pwd1)
assert verify_password(pwd2, h1) # bcrypt sees same first 72 bytes
# #endregion Test.SecurityOrthogonal.TestPasswordBcrypt72ByteLimit
# #region Test.SecurityOrthogonal.TestPasswordUnicodeNormalization [C:2] [TYPE Function]
# @BRIEF Unicode composed vs decomposed forms — bcrypt operates on bytes,
# so "é" (U+00E9) != "e" + combining accent (U+0065 U+0301).
# @TEST_EDGE: unicode_decomposition -> different byte sequences fail
def test_password_unicode_normalization(self):
"""Unicode normalization: NFC != NFD produces different hashes."""
import unicodedata
from src.core.auth.security import get_password_hash, verify_password
nfc = unicodedata.normalize("NFC", "café") # single codepoint é
nfd = unicodedata.normalize("NFD", "café") # e + combining accent
assert nfc != nfd # different byte sequences
h = get_password_hash(nfc)
assert verify_password(nfd, h) is False # should NOT match
# #endregion Test.SecurityOrthogonal.TestPasswordUnicodeNormalization
# #region Test.SecurityOrthogonal.TestPasswordEmptyAndWhitespace [C:2] [TYPE Function]
# @BRIEF Empty password, space-only password, null hash.
def test_password_empty_and_whitespace(self):
from src.core.auth.security import get_password_hash, verify_password
# Empty password — should hash and verify
h = get_password_hash("")
assert verify_password("", h)
# Space-only password
h = get_password_hash(" ")
assert verify_password(" ", h)
# Verify against None — should return False, not crash
assert not verify_password("password", None)
assert not verify_password("password", "")
# #endregion Test.SecurityOrthogonal.TestPasswordEmptyAndWhitespace
# ──────────────────────────────────────────────
# AUTH: API key format invariants
# ──────────────────────────────────────────────
class TestApiKeyFormat:
"""Orthogonal tests for API key generation — format invariants and edge cases."""
# #region Test.SecurityOrthogonal.TestApiKeyFormatInvariants [C:2] [TYPE Function]
# @BRIEF Every generated key must start with ssk_, prefix is exactly 11 chars,
# key_hash is 64 hex chars, raw_key contains only URL-safe base64.
def test_api_key_format_invariants(self):
from src.core.auth.api_key import generate_api_key
for _ in range(100):
raw, prefix, key_hash = generate_api_key()
assert raw.startswith("ssk_"), f"Key must start with ssk_: {raw[:10]}"
assert len(prefix) == 11, f"Prefix must be 11 chars: {prefix}"
assert prefix == raw[:11], f"Prefix mismatch: {prefix} != {raw[:11]}"
assert len(key_hash) == 64, f"Hash must be 64 hex chars: {key_hash}"
int(key_hash, 16) # must be valid hex — raises ValueError if not
# Raw key is URL-safe base64 after ssk_ prefix
body = raw[4:] # after "ssk_"
import string
allowed = set(string.ascii_letters + string.digits + "-_")
assert all(c in allowed for c in body), f"Invalid chars in key body: {body[:10]}"
# #endregion Test.SecurityOrthogonal.TestApiKeyFormatInvariants
# #region Test.SecurityOrthogonal.TestApiKeyHashUniqueness [C:2] [TYPE Function]
# @BRIEF 1000 generated keys must all have unique raw keys and unique hashes.
def test_api_key_hash_uniqueness(self):
from src.core.auth.api_key import generate_api_key
raws = set()
hashes = set()
prefixes = set()
for _ in range(1000):
raw, prefix, key_hash = generate_api_key()
raws.add(raw)
hashes.add(key_hash)
prefixes.add(prefix)
assert len(raws) == 1000 # all raws unique
assert len(hashes) == 1000 # all hashes unique
assert len(prefixes) <= 1000 # prefixes may collide (only 7 chars)
# #endregion Test.SecurityOrthogonal.TestApiKeyHashUniqueness
# #region Test.SecurityOrthogonal.TestHashApiKeyConsistency [C:2] [TYPE Function]
# @BRIEF hash_api_key is deterministic — same input = same output.
def test_hash_api_key_consistency(self):
from src.core.auth.api_key import hash_api_key
key = "ssk_test-key-for-consistency-check-123"
h1 = hash_api_key(key)
h2 = hash_api_key(key)
assert h1 == h2
assert len(h1) == 64
# #endregion Test.SecurityOrthogonal.TestHashApiKeyConsistency
# ──────────────────────────────────────────────
# ENCRYPTION: Fernet robustness
# ──────────────────────────────────────────────
class TestEncryptionRobustness:
"""Orthogonal tests for EncryptionManager — error handling on invalid data."""
@pytest.fixture(autouse=True)
def _setup_key(self):
"""Ensure ENCRYPTION_KEY is set."""
if not os.getenv("ENCRYPTION_KEY"):
from cryptography.fernet import Fernet
os.environ["ENCRYPTION_KEY"] = Fernet.generate_key().decode()
# #region Test.SecurityOrthogonal.TestEncryptDecryptEmpty [C:2] [TYPE Function]
# @BRIEF Empty string should encrypt and decrypt back to empty string.
def test_encrypt_decrypt_empty(self):
from src.core.encryption import EncryptionManager
mgr = EncryptionManager()
encrypted = mgr.encrypt("")
decrypted = mgr.decrypt(encrypted)
assert decrypted == ""
# #endregion Test.SecurityOrthogonal.TestEncryptDecryptEmpty
# #region Test.SecurityOrthogonal.TestDecryptInvalidData [C:2] [TYPE Function]
# @BRIEF Decrypting garbage, wrong key, truncated data must raise.
def test_decrypt_invalid_data(self):
from src.core.encryption import EncryptionManager
mgr = EncryptionManager()
# Garbage data
with pytest.raises(Exception):
mgr.decrypt("not-encrypted-data")
# Empty string
with pytest.raises(Exception):
mgr.decrypt("")
# Truncated valid token
valid = mgr.encrypt("test")
with pytest.raises(Exception):
mgr.decrypt(valid[:-10])
# #endregion Test.SecurityOrthogonal.TestDecryptInvalidData
# #region Test.SecurityOrthogonal.TestEncryptDecryptLargeData [C:2] [TYPE Function]
# @BRIEF Large strings (1MB) should encrypt/decrypt without error.
def test_encrypt_decrypt_large_data(self):
from src.core.encryption import EncryptionManager
mgr = EncryptionManager()
large = "x" * (1024 * 1024) # 1MB
encrypted = mgr.encrypt(large)
decrypted = mgr.decrypt(encrypted)
assert decrypted == large
# #endregion Test.SecurityOrthogonal.TestEncryptDecryptLargeData
# ──────────────────────────────────────────────
# ENCRYPTION KEY: lifecycle paths
# ──────────────────────────────────────────────
class TestEncryptionKeyLifecycle:
"""Orthogonal tests for ensure_encryption_key — resolution strategy."""
# #region Test.SecurityOrthogonal.TestEnsureEncryptionKeyFromEnv [C:2] [TYPE Function]
# @BRIEF When ENCRYPTION_KEY is set in environment, return it without side effects.
def test_ensure_encryption_key_from_env(self):
from cryptography.fernet import Fernet
from src.core.encryption_key import ensure_encryption_key
expected = Fernet.generate_key().decode()
with patch.dict(os.environ, {"ENCRYPTION_KEY": expected}, clear=False):
# Ensure no .env file is accessed — use a non-existent path
result = ensure_encryption_key(Path("/nonexistent/.env"))
assert result == expected
# #endregion Test.SecurityOrthogonal.TestEnsureEncryptionKeyFromEnv
# #region Test.SecurityOrthogonal.TestEnsureEncryptionKeyMissingRaises [C:2] [TYPE Function]
# @BRIEF When neither env nor .env has a key, crash-early with RuntimeError.
# @TEST_EDGE: missing_encryption_key -> RuntimeError with clear message
def test_ensure_encryption_key_missing_raises(self):
from src.core.encryption_key import ensure_encryption_key
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
f.write("# empty .env\n")
env_path = Path(f.name)
try:
old = os.environ.pop("ENCRYPTION_KEY", None)
try:
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY"):
ensure_encryption_key(env_path)
finally:
if old is not None:
os.environ["ENCRYPTION_KEY"] = old
finally:
env_path.unlink(missing_ok=True)
# #endregion Test.SecurityOrthogonal.TestEnsureEncryptionKeyMissingRaises
# #region Test.SecurityOrthogonal.TestEnsureEncryptionKeyLoadsFromFile [C:2] [TYPE Function]
# @BRIEF When env is empty but .env file has the key, load it.
def test_ensure_encryption_key_loads_from_file(self):
from cryptography.fernet import Fernet
from src.core.encryption_key import ensure_encryption_key
key = Fernet.generate_key().decode()
old = os.environ.pop("ENCRYPTION_KEY", None)
try:
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
f.write(f"ENCRYPTION_KEY={key}\n")
env_path = Path(f.name)
try:
result = ensure_encryption_key(env_path)
assert result == key
assert os.environ["ENCRYPTION_KEY"] == key
finally:
env_path.unlink(missing_ok=True)
finally:
if old is not None:
os.environ["ENCRYPTION_KEY"] = old
# #endregion Test.SecurityOrthogonal.TestEnsureEncryptionKeyLoadsFromFile
# ──────────────────────────────────────────────
# AUTH LOGGER: log injection protection
# ──────────────────────────────────────────────
class TestLogInjectionProtection:
"""Orthogonal tests for auth logger — verify CRLF/control chars don't forge logs."""
# #region Test.SecurityOrthogonal.TestLogSecurityEventInjection [C:2] [TYPE Function]
# @BRIEF CRLF in username or event_type must not create forged log entries.
# Our fix uses %s-formatting which prevents CRLF injection.
def test_log_security_event_injection(self):
from src.core.auth.logger import log_security_event
# Username with embedded newline — this would forge a log entry with plain f-strings
malicious = "admin\n[AUDIT][FAKE] User: attacker"
# Should not raise, should not create a fake entry
log_security_event("LOGIN_SUCCESS", malicious, {"source": "test"})
# (We verify by checking that no exception occurs — actual log inspection
# would require a log handler fixture)
# #endregion Test.SecurityOrthogonal.TestLogSecurityEventInjection
# #region Test.SecurityOrthogonal.TestLogSecurityEventLongInput [C:2] [TYPE Function]
# @BRIEF Very long username/event_type must not crash the logger.
def test_log_security_event_long_input(self):
from src.core.auth.logger import log_security_event
long_username = "u" * 10000
log_security_event("TEST_EVENT", long_username, {"detail": "x" * 10000})
# #endregion Test.SecurityOrthogonal.TestLogSecurityEventLongInput
# #region Test.SecurityOrthogonal.TestLogSecurityEventSpecialChars [C:2] [TYPE Function]
# @BRIEF Unicode, control chars, emoji in username must not break logging.
def test_log_security_event_special_chars(self):
from src.core.auth.logger import log_security_event
log_security_event("LOGIN_SUCCESS", "user@domain.com\u0000null_bytes")
log_security_event("LOGIN_SUCCESS", "user\u0001\u0002\u001fcontrol_chars")
log_security_event("LOGIN_SUCCESS", "user🔥emoji")
log_security_event("LOGIN_SUCCESS", "пользователь_кириллица")
# #endregion Test.SecurityOrthogonal.TestLogSecurityEventSpecialChars
# ──────────────────────────────────────────────
# DEPENDENCIES: JWT edge cases
# ──────────────────────────────────────────────
class TestJwtEdgeCases:
"""Orthogonal tests for JWT token handling — edge cases in decode/validation."""
# #region Test.SecurityOrthogonal.TestDecodeExpiredToken [C:2] [TYPE Function]
# @BRIEF Token with past expiration must raise JWTError.
def test_decode_expired_token(self):
# Create an already-expired token
import time
from jose import JWTError, jwt
from src.core.auth.config import auth_config
payload = {"sub": "testuser", "exp": int(time.time()) - 3600} # 1 hour ago
token = jwt.encode(payload, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM)
from src.core.auth.jwt import decode_token
with pytest.raises(JWTError):
decode_token(token)
# #endregion Test.SecurityOrthogonal.TestDecodeExpiredToken
# #region Test.SecurityOrthogonal.TestDecodeMalformedToken [C:2] [TYPE Function]
# @BRIEF Malformed JWT (wrong format, wrong key) must raise JWTError.
def test_decode_malformed_token(self):
from jose import JWTError
from src.core.auth.jwt import decode_token
with pytest.raises(JWTError):
decode_token("not.a.token")
with pytest.raises(JWTError):
decode_token("")
# Token with wrong algorithm
from jose import jwt
token = jwt.encode({"sub": "test"}, "wrong-key", algorithm="HS256")
with pytest.raises(JWTError):
decode_token(token)
# #endregion Test.SecurityOrthogonal.TestDecodeMalformedToken
# #region Test.SecurityOrthogonal.TestDecodeTokenMissingSub [C:2] [TYPE Function]
# @BRIEF Token without 'sub' claim should decode but downstream should handle.
def test_decode_token_missing_sub(self):
from src.core.auth.jwt import create_access_token, decode_token
token = create_access_token(data={"role": "admin"}) # no 'sub'
payload = decode_token(token)
assert "sub" not in payload
assert payload.get("role") == "admin"
# #endregion Test.SecurityOrthogonal.TestDecodeTokenMissingSub
# ──────────────────────────────────────────────
# RATE LIMITER
# ──────────────────────────────────────────────
class TestRateLimiter:
"""Orthogonal tests for in-memory rate limiter."""
# #region Test.SecurityOrthogonal.RateLimiterDefaultConfig [C:1] [TYPE Function]
@pytest.fixture
def _rate_limiter_default_config(self):
"""Pin the rate limiter to module constants and clear cross-test cache pollution.
_read_config caches GlobalSettings for a 60s TTL. A settings test that caches
auth_max_attempts=1 can poison the module-level _config_cache; the next rate-limiter
behavior test then reads max_attempts=1 and wrongly bans after 5 attempts. Pinning
the cache to the module defaults makes these behavior tests deterministic.
"""
import time
import src.core.rate_limiter as _rl
_rl._config_cache = {
"max_attempts": _rl.MAX_ATTEMPTS,
"attempt_window": _rl.ATTEMPT_WINDOW,
"ban_duration": _rl.BAN_DURATION,
}
_rl._config_cache_at = time.monotonic()
yield
_rl._config_cache = None
_rl._config_cache_at = 0.0
# #endregion Test.SecurityOrthogonal.RateLimiterDefaultConfig
# #region Test.SecurityOrthogonal.TestRateLimiterAllowsUnderLimit [C:2] [TYPE Function]
# @BRIEF Under limit: not banned, attempts recorded.
def test_rate_limiter_allows_under_limit(self, _rate_limiter_default_config):
from src.core.rate_limiter import RateLimiter
rl = RateLimiter()
assert not rl.is_banned("1.2.3.4")
for _ in range(5):
rl.record_attempt("1.2.3.4")
assert not rl.is_banned("1.2.3.4")
# #endregion Test.SecurityOrthogonal.TestRateLimiterAllowsUnderLimit
# #region Test.SecurityOrthogonal.TestRateLimiterBansOverLimit [C:2] [TYPE Function]
# @BRIEF Over limit: IP is banned, success clears history.
def test_rate_limiter_bans_over_limit(self, _rate_limiter_default_config):
from src.core.rate_limiter import MAX_ATTEMPTS, RateLimiter
rl = RateLimiter()
ip = "5.6.7.8"
for _ in range(MAX_ATTEMPTS + 1):
rl.record_attempt(ip)
assert rl.is_banned(ip)
# After ban, success should clear (but may still be banned)
rl.record_success(ip)
# #endregion Test.SecurityOrthogonal.TestRateLimiterBansOverLimit
# #region Test.SecurityOrthogonal.TestRateLimiterSuccessClearsHistory [C:2] [TYPE Function]
# @BRIEF Successful auth clears attempt history for that IP.
def test_rate_limiter_success_clears_history(self, _rate_limiter_default_config):
from src.core.rate_limiter import RateLimiter
rl = RateLimiter()
rl.record_attempt("9.9.9.9")
rl.record_attempt("9.9.9.9")
rl.record_success("9.9.9.9")
# After success, the attempts dict is cleared — we can make more without ban
for _ in range(5):
rl.record_attempt("9.9.9.9")
assert not rl.is_banned("9.9.9.9")
# #endregion Test.SecurityOrthogonal.TestRateLimiterSuccessClearsHistory
# #region Test.SecurityOrthogonal.TestRateLimiterDifferentIpsIndependent [C:2] [TYPE Function]
# @BRIEF Different IPs are tracked independently.
def test_rate_limiter_different_ips_independent(self):
from src.core.rate_limiter import MAX_ATTEMPTS, RateLimiter
rl = RateLimiter()
# One IP gets banned
for _ in range(MAX_ATTEMPTS + 1):
rl.record_attempt("bad-ip")
assert rl.is_banned("bad-ip")
# Other IP is not affected
assert not rl.is_banned("good-ip")
# #endregion Test.SecurityOrthogonal.TestRateLimiterDifferentIpsIndependent
# ──────────────────────────────────────────────
# PASSWORD POLICY
# ──────────────────────────────────────────────
class TestPasswordPolicy:
"""Orthogonal tests for password strength validation."""
# #region Test.SecurityOrthogonal.TestPasswordTooShort [C:2] [TYPE Function]
def test_password_too_short(self):
from pydantic import ValidationError
from src.schemas.auth import UserCreate
with pytest.raises(ValidationError):
UserCreate(username="test", password="Ab1") # only 3 chars
# #endregion Test.SecurityOrthogonal.TestPasswordTooShort
# #region Test.SecurityOrthogonal.TestPasswordNoUppercase [C:2] [TYPE Function]
def test_password_no_uppercase(self):
from pydantic import ValidationError
from src.schemas.auth import UserCreate
with pytest.raises(ValidationError):
UserCreate(username="test", password="abcdefgh1") # no uppercase
# #endregion Test.SecurityOrthogonal.TestPasswordNoUppercase
# #region Test.SecurityOrthogonal.TestPasswordNoDigit [C:2] [TYPE Function]
def test_password_no_digit(self):
from pydantic import ValidationError
from src.schemas.auth import UserCreate
with pytest.raises(ValidationError):
UserCreate(username="test", password="Abcdefgh") # no digit
# #endregion Test.SecurityOrthogonal.TestPasswordNoDigit
# #region Test.SecurityOrthogonal.TestPasswordValid [C:2] [TYPE Function]
def test_password_valid(self):
from src.schemas.auth import UserCreate
u = UserCreate(username="test", password="ValidPass1")
assert u.password == "ValidPass1"
# #endregion Test.SecurityOrthogonal.TestPasswordValid
# ──────────────────────────────────────────────
# LOG MASKING
# ──────────────────────────────────────────────
class TestLogMasking:
"""Orthogonal tests for sensitive field masking in log_security_event."""
# #region Test.SecurityOrthogonal.TestMaskSensitiveFields [C:2] [TYPE Function]
def test_mask_sensitive_fields(self):
from src.core.auth.logger import _mask_details
masked = _mask_details({
"username": "john",
"password": "supersecret",
"token": "eyJhbGciOiJIUzI1NiJ9.xxx",
"api_key": "ssk_abc123",
"source": "LOCAL",
})
assert masked["username"] == "john"
assert masked["password"] == "***"
assert masked["token"] == "***"
assert masked["api_key"] == "***"
assert masked["source"] == "LOCAL"
# #endregion Test.SecurityOrthogonal.TestMaskSensitiveFields
# #region Test.SecurityOrthogonal.TestMaskNestedDetails [C:2] [TYPE Function]
def test_mask_nested_details(self):
from src.core.auth.logger import _mask_details
masked = _mask_details({
"user": {"username": "john", "password": "secret"},
})
assert masked["user"]["username"] == "john"
assert masked["user"]["password"] == "***"
# #endregion Test.SecurityOrthogonal.TestMaskNestedDetails
# #endregion Test.SecurityOrthogonal.TestSecurityOrthogonal