File size: 6,339 Bytes
76946d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""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"