195 lines
6.8 KiB
Python
195 lines
6.8 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Tests for the single-user auth gate.
|
|
|
|
The review UI previously had no authentication at all, reachable by anything
|
|
on the LAN and (via Caddy) from the internet. These cover the credential
|
|
store, the password rule, and the tamper-resistance of the session token.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from umbraaudio import auth
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
"""Keep every test out of the real ~/.umbra credential store."""
|
|
store = tmp_path / ".umbra"
|
|
store.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setattr(auth, "_store_dir", lambda: store)
|
|
return store
|
|
|
|
|
|
# ------------------------------------------------------------ password rule
|
|
|
|
|
|
def test_password_rule_requires_more_than_six_chars() -> None:
|
|
assert auth.validate_password("abc") is not None
|
|
assert auth.validate_password("abcdef1") is not None # no special
|
|
assert auth.validate_password("1234567") is not None # no letter
|
|
|
|
|
|
def test_password_rule_accepts_seven_plus_with_alpha_and_special() -> None:
|
|
assert auth.validate_password("abcdef!") is None
|
|
assert auth.validate_password("A1!zzzz") is None
|
|
|
|
|
|
def test_bootstrap_password_is_alphanumeric_so_it_fails_the_new_rule() -> None:
|
|
"""First321 must be usable to log in, but unacceptable as the new password."""
|
|
assert auth.validate_password(auth.BOOTSTRAP_PASSWORD) is not None
|
|
|
|
|
|
# ------------------------------------------------------------- seeding
|
|
|
|
|
|
def test_first_run_seeds_the_one_time_password_and_forces_a_change() -> None:
|
|
cred = auth.ensure_credential()
|
|
assert cred.username == auth.USERNAME
|
|
assert cred.must_change is True
|
|
assert auth.check_login(auth.USERNAME, auth.BOOTSTRAP_PASSWORD) is not None
|
|
|
|
|
|
def test_bootstrap_password_is_never_stored_in_plaintext() -> None:
|
|
auth.ensure_credential()
|
|
raw = auth.auth_file().read_text()
|
|
assert auth.BOOTSTRAP_PASSWORD not in raw
|
|
assert "hash" in raw and "salt" in raw
|
|
|
|
|
|
def test_credential_file_is_owner_only() -> None:
|
|
auth.ensure_credential()
|
|
assert oct(auth.auth_file().stat().st_mode)[-3:] == "600"
|
|
|
|
|
|
# ------------------------------------------------------------- login
|
|
|
|
|
|
def test_wrong_username_and_wrong_password_both_denied() -> None:
|
|
auth.ensure_credential()
|
|
assert auth.check_login(auth.USERNAME, "nope!!") is None
|
|
assert auth.check_login("someone-else", auth.BOOTSTRAP_PASSWORD) is None
|
|
|
|
|
|
def test_password_change_invalidates_the_old_password() -> None:
|
|
auth.ensure_credential()
|
|
assert auth.set_password(auth.BOOTSTRAP_PASSWORD, "BrandNew!1") is None
|
|
assert auth.check_login(auth.USERNAME, auth.BOOTSTRAP_PASSWORD) is None
|
|
assert auth.check_login(auth.USERNAME, "BrandNew!1") is not None
|
|
|
|
|
|
def test_password_change_requires_the_correct_current_password() -> None:
|
|
auth.ensure_credential()
|
|
assert auth.set_password("wrong!!", "BrandNew!1") is not None
|
|
|
|
|
|
def test_password_change_clears_the_must_change_flag() -> None:
|
|
auth.ensure_credential()
|
|
auth.set_password(auth.BOOTSTRAP_PASSWORD, "BrandNew!1")
|
|
assert auth.ensure_credential().must_change is False
|
|
|
|
|
|
def test_password_cannot_be_reused_verbatim() -> None:
|
|
auth.ensure_credential()
|
|
err = auth.set_password(auth.BOOTSTRAP_PASSWORD, "Another!22")
|
|
assert err is None
|
|
assert auth.set_password("Another!22", "Another!22") is not None
|
|
|
|
|
|
def test_corrupt_store_is_treated_as_unset_not_fatal() -> None:
|
|
auth.ensure_credential()
|
|
auth.auth_file().write_text("{not json")
|
|
# Must not raise, and must re-seed rather than lock the user out.
|
|
assert auth.ensure_credential().username == auth.USERNAME
|
|
|
|
|
|
# ------------------------------------------------------------- sessions
|
|
|
|
|
|
def test_session_round_trips() -> None:
|
|
token = auth.issue_session(auth.USERNAME)
|
|
assert auth.verify_session(token) == auth.USERNAME
|
|
|
|
|
|
def test_tampered_session_is_rejected() -> None:
|
|
token = auth.issue_session(auth.USERNAME)
|
|
body, _, sig = token.partition(".")
|
|
forged_body = body[:-2] + ("aa" if not body.endswith("aa") else "bb")
|
|
assert auth.verify_session(f"{forged_body}.{sig}") is None
|
|
|
|
|
|
def test_forged_signature_is_rejected() -> None:
|
|
token = auth.issue_session(auth.USERNAME)
|
|
body, _, _sig = token.partition(".")
|
|
assert auth.verify_session(f"{body}.{auth._b64e(b'wrong')}") is None
|
|
|
|
|
|
def test_expired_session_is_rejected() -> None:
|
|
token = auth.issue_session(auth.USERNAME, ttl=-10)
|
|
assert auth.verify_session(token) is None
|
|
|
|
|
|
def test_malformed_session_values_are_rejected_not_raised() -> None:
|
|
for bad in (None, "", "no-dot", "...", "a.b.c"):
|
|
assert auth.verify_session(bad) is None
|
|
|
|
|
|
def test_session_payload_carries_no_password_material() -> None:
|
|
token = auth.issue_session(auth.USERNAME)
|
|
assert auth.BOOTSTRAP_PASSWORD not in token
|
|
payload = json.loads(auth._b64d(token.partition(".")[0]))
|
|
assert set(payload) == {"u", "exp"}
|
|
|
|
|
|
def test_session_secret_is_stable_across_calls(isolated_store: Path) -> None:
|
|
# The fixture is load-bearing: without it this test read and rewrote the
|
|
# REAL .umbra/session_secret, so running the suite mutated production state.
|
|
a = auth._session_secret()
|
|
b = auth._session_secret()
|
|
assert a == b
|
|
assert len(a) == 32
|
|
|
|
|
|
def test_secret_with_whitespace_bytes_is_returned_verbatim(isolated_store: Path) -> None:
|
|
"""Regression: ``.strip()`` silently shortened ~4% of generated secrets.
|
|
|
|
``bytes.strip()`` removes ASCII whitespace from both ends, so a secret
|
|
beginning or ending with one of those six bytes was returned one byte
|
|
shorter on the next read than the value just written - a different key.
|
|
"""
|
|
for lead in (b"", b" ", b"\t", b"\n", b"\r", b"\x0b", b"\x0c"):
|
|
for tail in (b"", b" ", b"\t", b"\n", b"\r", b"\x0b", b"\x0c"):
|
|
raw = lead + secrets.token_bytes(32) + tail
|
|
path = auth._secret_file()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(raw)
|
|
assert auth._session_secret() == raw, f"mutated for {lead!r}...{tail!r}"
|
|
|
|
|
|
def test_truncated_secret_is_regenerated(isolated_store: Path) -> None:
|
|
path = auth._secret_file()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"tiny")
|
|
got = auth._session_secret()
|
|
assert len(got) == auth.SECRET_BYTES
|
|
assert got != b"tiny"
|
|
|
|
|
|
def test_token_survives_a_round_trip_with_whitespace_secret(
|
|
isolated_store: Path,
|
|
) -> None:
|
|
"""The user-visible symptom: a session that can never be verified."""
|
|
raw = b" " + secrets.token_bytes(32) + b"\n"
|
|
path = auth._secret_file()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(raw)
|
|
token = auth.issue_session(auth.USERNAME)
|
|
assert auth.verify_session(token) is not None
|