Files
UmbraAudio/tests/test_auth.py
2026-10-18 19:01:04 -07:00

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