153 lines
4.5 KiB
Python
153 lines
4.5 KiB
Python
|
|
import json
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from furtka import auth
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def tmp_users_file(tmp_path, monkeypatch):
|
||
|
|
path = tmp_path / "users.json"
|
||
|
|
monkeypatch.setenv("FURTKA_USERS_FILE", str(path))
|
||
|
|
# Sessions are module-level; wipe between tests so one doesn't leak a
|
||
|
|
# valid token into the next.
|
||
|
|
auth.SESSIONS.clear()
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
def test_hash_password_roundtrip():
|
||
|
|
h = auth.hash_password("hunter2")
|
||
|
|
assert h != "hunter2" # Not plain text.
|
||
|
|
assert auth.verify_password("hunter2", h) is True
|
||
|
|
assert auth.verify_password("hunter3", h) is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_hash_password_is_salted():
|
||
|
|
# Two calls with the same password must produce different hashes.
|
||
|
|
a = auth.hash_password("same")
|
||
|
|
b = auth.hash_password("same")
|
||
|
|
assert a != b
|
||
|
|
# But both verify against the original.
|
||
|
|
assert auth.verify_password("same", a)
|
||
|
|
assert auth.verify_password("same", b)
|
||
|
|
|
||
|
|
|
||
|
|
def test_load_users_returns_empty_when_missing(tmp_users_file):
|
||
|
|
assert not tmp_users_file.exists()
|
||
|
|
assert auth.load_users() == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_load_users_returns_empty_on_junk(tmp_users_file):
|
||
|
|
tmp_users_file.write_text("{not json")
|
||
|
|
assert auth.load_users() == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_load_users_returns_empty_on_non_dict(tmp_users_file):
|
||
|
|
tmp_users_file.write_text("[]")
|
||
|
|
assert auth.load_users() == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_save_users_atomic_and_0600(tmp_users_file):
|
||
|
|
auth.save_users({"admin": {"hash": "x", "username": "daniel"}})
|
||
|
|
assert tmp_users_file.exists()
|
||
|
|
mode = tmp_users_file.stat().st_mode & 0o777
|
||
|
|
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
|
||
|
|
loaded = json.loads(tmp_users_file.read_text())
|
||
|
|
assert loaded["admin"]["username"] == "daniel"
|
||
|
|
|
||
|
|
|
||
|
|
def test_setup_needed_true_on_missing_file(tmp_users_file):
|
||
|
|
assert auth.setup_needed() is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_setup_needed_true_on_empty_dict(tmp_users_file):
|
||
|
|
tmp_users_file.write_text("{}")
|
||
|
|
assert auth.setup_needed() is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_setup_needed_false_when_admin_exists(tmp_users_file):
|
||
|
|
auth.create_admin("daniel", "secret-pw")
|
||
|
|
assert auth.setup_needed() is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_create_admin_overwrites_file(tmp_users_file):
|
||
|
|
auth.create_admin("daniel", "secret-pw")
|
||
|
|
auth.create_admin("robert", "new-pw")
|
||
|
|
users = auth.load_users()
|
||
|
|
assert users["admin"]["username"] == "robert"
|
||
|
|
|
||
|
|
|
||
|
|
def test_authenticate_happy(tmp_users_file):
|
||
|
|
auth.create_admin("daniel", "secret-pw")
|
||
|
|
assert auth.authenticate("daniel", "secret-pw") is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_authenticate_wrong_username(tmp_users_file):
|
||
|
|
auth.create_admin("daniel", "secret-pw")
|
||
|
|
assert auth.authenticate("robert", "secret-pw") is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_authenticate_wrong_password(tmp_users_file):
|
||
|
|
auth.create_admin("daniel", "secret-pw")
|
||
|
|
assert auth.authenticate("daniel", "wrong") is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_authenticate_no_admin(tmp_users_file):
|
||
|
|
assert auth.authenticate("daniel", "anything") is False
|
||
|
|
|
||
|
|
|
||
|
|
# ---- Session store ---------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_session_create_and_lookup(tmp_users_file):
|
||
|
|
s = auth.SESSIONS.create("daniel")
|
||
|
|
assert s.username == "daniel"
|
||
|
|
assert s.token
|
||
|
|
looked_up = auth.SESSIONS.lookup(s.token)
|
||
|
|
assert looked_up is not None
|
||
|
|
assert looked_up.username == "daniel"
|
||
|
|
|
||
|
|
|
||
|
|
def test_session_lookup_unknown_token(tmp_users_file):
|
||
|
|
assert auth.SESSIONS.lookup("not-a-real-token") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_session_lookup_none_token(tmp_users_file):
|
||
|
|
assert auth.SESSIONS.lookup(None) is None
|
||
|
|
assert auth.SESSIONS.lookup("") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_session_revoke(tmp_users_file):
|
||
|
|
s = auth.SESSIONS.create("daniel")
|
||
|
|
auth.SESSIONS.revoke(s.token)
|
||
|
|
assert auth.SESSIONS.lookup(s.token) is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_session_expires(tmp_users_file, monkeypatch):
|
||
|
|
# Build a session store with a 0-second TTL so lookup immediately
|
||
|
|
# treats new sessions as expired.
|
||
|
|
store = auth.SessionStore(ttl_seconds=0)
|
||
|
|
s = store.create("daniel")
|
||
|
|
# Force the clock forward a hair so the > check fires.
|
||
|
|
monkeypatch.setattr(
|
||
|
|
auth,
|
||
|
|
"datetime",
|
||
|
|
_FakeDatetime(datetime.now(UTC) + timedelta(seconds=1)),
|
||
|
|
)
|
||
|
|
# The module-local datetime reference inside SessionStore.lookup
|
||
|
|
# resolves at call time. Verify that an expired session is dropped.
|
||
|
|
assert store.lookup(s.token) is None
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeDatetime:
|
||
|
|
"""Tiny shim — only `.now(tz)` is used from SessionStore."""
|
||
|
|
|
||
|
|
def __init__(self, fixed_utc):
|
||
|
|
self._fixed = fixed_utc
|
||
|
|
|
||
|
|
def now(self, tz=None):
|
||
|
|
if tz is None:
|
||
|
|
return self._fixed.replace(tzinfo=None)
|
||
|
|
return self._fixed.astimezone(tz)
|