"""Unit tests for hosting_panel.auth.SecretVault. Covers the two-tier encryption roundtrip and the legacy single-tier fallback path -- this is what protects every bot secret at rest. """ from __future__ import annotations import os os.environ.setdefault("PANEL_SECRET_KEY", "x" * 64) os.environ.setdefault("PANEL_ENCRYPTION_KEY", "90mbg1KLmFKBpoSs5nstm7Ew-p3Q-J3ya3Zbuhwbtho=") import pytest from hosting_panel.auth import VAULT # ---- basics --------------------------------------------------------------- def test_vault_is_configured_when_env_present(): assert VAULT.is_configured is True def test_generate_user_key_is_urlsafe_base64_44chars(): """User key is a raw Fernet key, not a Fernet-encrypted token.""" from cryptography.fernet import Fernet k = VAULT.generate_user_key() assert isinstance(k, str) assert len(k) == 44, f"unexpected length: {len(k)}" # urlsafe-base64 alphabet: A-Z, a-z, 0-9, _, -, = import re assert re.fullmatch(r"[A-Za-z0-9_\-=]+", k), f"unexpected chars: {k!r}" # And the key actually works as a Fernet key (round-trip). f = Fernet(k.encode()) ct = f.encrypt(b"hi") assert f.decrypt(ct) == b"hi" # ---- single-tier (legacy) ------------------------------------------------- def test_single_tier_roundtrip(): ct = VAULT.encrypt("hello") assert VAULT.decrypt(ct) == "hello" def test_single_tier_readable_without_user_key(): ct = VAULT.encrypt("hello") # user_key=None falls back to single-tier decrypt. assert VAULT.decrypt(ct, user_key=None) == "hello" def test_empty_plaintext_passes_through(): assert VAULT.encrypt("") == "" assert VAULT.decrypt("") == "" def test_empty_ciphertext_returns_empty(): assert VAULT.decrypt(None) == "" assert VAULT.decrypt("") == "" # ---- layered -------------------------------------------------------------- def test_layered_roundtrip(): user_key = VAULT.generate_user_key() ct = VAULT.encrypt("secret-payload", user_key=user_key) # Two layers -> ciphertext is much longer than plaintext. assert len(ct) > len("secret-payload") * 2 assert VAULT.decrypt(ct, user_key=user_key) == "secret-payload" def test_wrong_user_key_falls_back_to_valueerror(): """Wrong user key on layered data: both paths fail -> ValueError.""" user_key = VAULT.generate_user_key() ct = VAULT.encrypt("secret", user_key=user_key) other_key = VAULT.generate_user_key() with pytest.raises(ValueError): VAULT.decrypt(ct, user_key=other_key) def test_no_user_key_on_layered_fails(): """Layered data without the user key is NOT silently readable.""" user_key = VAULT.generate_user_key() ct = VAULT.encrypt("secret", user_key=user_key) # No user_key means only single-tier fallback -> can't decrypt layered. with pytest.raises(ValueError): VAULT.decrypt(ct, user_key=None) def test_unicode_payload_roundtrip(): user_key = VAULT.generate_user_key() payload = "Привіт, Слава Україні! 🇺🇦 — emoji & unicodé" ct = VAULT.encrypt(payload, user_key=user_key) assert VAULT.decrypt(ct, user_key=user_key) == payload def test_long_payload_roundtrip(): user_key = VAULT.generate_user_key() payload = "x" * 100_000 ct = VAULT.encrypt(payload, user_key=user_key) assert VAULT.decrypt(ct, user_key=user_key) == payload def test_layered_then_legacy_fallback_works_on_single_tier_data(): """Single-tier ciphertext is readable with ANY user_key (fallback path).""" user_key = VAULT.generate_user_key() ct = VAULT.encrypt("plain-secret") # single-tier assert VAULT.decrypt(ct, user_key=user_key) == "plain-secret" # ---- password hashing ----------------------------------------------------- def test_password_hash_and_verify(): from hosting_panel.auth import hash_password, verify_password h = hash_password("correct-horse-battery-staple") assert h != "correct-horse-battery-staple" assert verify_password("correct-horse-battery-staple", h) is True assert verify_password("wrong-password", h) is False # Same hash verifies twice (no state mutation). assert verify_password("correct-horse-battery-staple", h) is True def test_password_hash_uses_bcrypt_format(): from hosting_panel.auth import hash_password h = hash_password("hunter2") # bcrypt hashes start with $2a$ / $2b$ / $2y$ assert h.startswith("$2"), f"not a bcrypt hash: {h[:10]}" # ---- session serializer --------------------------------------------------- def _make_request(cookies: dict): """Build a minimal Starlette Request with given cookies.""" from starlette.requests import Request scope = { "type": "http", "method": "GET", "path": "/", "headers": [(b"cookie", f"session={cookies['session']}".encode())] if "session" in cookies else [], "query_string": b"", } return Request(scope) def test_session_roundtrip(): from hosting_panel.auth import create_session, parse_session token = create_session(user_id=42, username="alice", role="admin") su = parse_session(token) assert su is not None assert su.user_id == 42 assert su.username == "alice" assert su.role == "admin" def test_session_tampering_fails(): from hosting_panel.auth import create_session, parse_session token = create_session(user_id=1, username="a", role="admin") # Flip last two chars. tampered = token[:-2] + ("AB" if token[-2:] != "AB" else "CD") assert parse_session(tampered) is None def test_session_empty_token_returns_none(): """Garbage / empty input -> None (no crash).""" from hosting_panel.auth import parse_session assert parse_session("") is None assert parse_session("not-a-real-token") is None assert parse_session("garbage.with.dots") is None def test_get_session_user_from_cookie(): """End-to-end: cookie -> SessionUser.""" from starlette.requests import Request from hosting_panel.auth import create_session, get_session_user token = create_session(user_id=7, username="bob", role="operator") request = _make_request({"session": token}) su = get_session_user(request) assert su is not None assert su.user_id == 7 assert su.role == "operator"