Finance-Accounts/ar-aging-app/backend/tests/test_auth.py

220 lines
9.4 KiB
Python

"""Login: AR_AUTH=auto turns on with the first user; identity feeds sign-off fields."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.api import auth as auth_mod
from app.api.main import app
from app.db import models
from app.db.database import SessionLocal, init_db
@pytest.fixture()
def clean_users():
"""Users flip AR_AUTH=auto to 'required' for the WHOLE shared test DB — always remove
them again so the rest of the suite keeps running unauthenticated."""
init_db()
yield
db = SessionLocal()
try:
db.query(models.User).delete()
db.commit()
finally:
db.close()
auth_mod.invalidate_users_cache()
def _add_user(username: str, display: str, password: str) -> None:
db = SessionLocal()
try:
db.add(models.User(username=username, display_name=display,
password_hash=auth_mod.hash_password(password), is_active=True))
db.commit()
finally:
db.close()
auth_mod.invalidate_users_cache()
def test_password_hash_roundtrip():
h = auth_mod.hash_password("s3cret-pw!")
assert h.startswith("scrypt$")
assert auth_mod.verify_password("s3cret-pw!", h)
assert not auth_mod.verify_password("wrong", h)
assert not auth_mod.verify_password("s3cret-pw!", "garbage")
def test_token_roundtrip_and_tamper():
user = models.User(id=7, username="jane", display_name="Jane D",
password_hash="x", is_active=True)
tok = auth_mod.create_token(user)
data = auth_mod.parse_token(tok)
assert data and data["uid"] == 7 and data["dn"] == "Jane D"
payload, sig = tok.split(".")
assert auth_mod.parse_token(f"{payload}x.{sig}") is None # tampered payload
assert auth_mod.parse_token(f"{payload}.{sig[:-2]}aa") is None # tampered signature
def test_api_open_with_no_users(clean_users):
auth_mod.invalidate_users_cache()
with TestClient(app) as c:
assert c.get("/api/auth/status").json()["auth_required"] is False
assert c.get("/api/sessions").status_code == 200
assert c.get("/api/auth/me").json()["authenticated"] is False
def test_first_user_turns_auth_on_and_login_works(clean_users):
_add_user("talha", "Talha Ahmed", "correct-horse-9")
with TestClient(app) as c:
assert c.get("/api/auth/status").json()["auth_required"] is True
# Locked out without a token; health stays open for probes.
assert c.get("/api/sessions").status_code == 401
assert c.get("/api/health").status_code == 200
assert c.post("/api/auth/login", json={
"username": "talha", "password": "nope"}).status_code == 401
assert c.post("/api/auth/login", json={
"username": "ghost", "password": "correct-horse-9"}).status_code == 401
r = c.post("/api/auth/login", json={"username": "TALHA", # case-insensitive
"password": "correct-horse-9"})
assert r.status_code == 200
token = r.json()["token"]
assert r.json()["user"]["display_name"] == "Talha Ahmed"
hdr = {"Authorization": f"Bearer {token}"}
assert c.get("/api/sessions", headers=hdr).status_code == 200
me = c.get("/api/auth/me", headers=hdr).json()
assert me["authenticated"] and me["display_name"] == "Talha Ahmed"
def test_signed_in_identity_overrides_body_name(clean_users):
"""Accountability fields record the VERIFIED identity, not whatever the body claims."""
_add_user("ayesha", "Ayesha K", "another-pw-123")
with TestClient(app) as c:
token = c.post("/api/auth/login", json={
"username": "ayesha", "password": "another-pw-123"}).json()["token"]
hdr = {"Authorization": f"Bearer {token}"}
sid = c.post("/api/sessions", json={"name": "identity", "month_end_date": "2029-01-31",
"allow_duplicate": True}, headers=hdr).json()["id"]
r = c.post(f"/api/sessions/{sid}/reconciliation-control/verify",
json={"verified_by": "Somebody Else", "comment": "spoof attempt"},
headers=hdr)
assert r.status_code == 200
db = SessionLocal()
try:
fc = db.query(models.FinanceControl).filter_by(session_id=sid).first()
assert fc.verified_by == "Ayesha K" # not "Somebody Else"
finally:
db.close()
c.delete(f"/api/sessions/{sid}", headers=hdr)
def test_change_password_flow(clean_users):
_add_user("changer", "Change Person", "old-password-1")
with TestClient(app) as c:
token = c.post("/api/auth/login", json={
"username": "changer", "password": "old-password-1"}).json()["token"]
hdr = {"Authorization": f"Bearer {token}"}
# Wrong current password / too short / unchanged are all refused.
assert c.post("/api/auth/change-password", headers=hdr, json={
"current_password": "nope", "new_password": "new-password-2"}).status_code == 400
assert c.post("/api/auth/change-password", headers=hdr, json={
"current_password": "old-password-1", "new_password": "short"}).status_code == 400
assert c.post("/api/auth/change-password", headers=hdr, json={
"current_password": "old-password-1",
"new_password": "old-password-1"}).status_code == 400
# Not signed in -> refused.
assert c.post("/api/auth/change-password", json={
"current_password": "old-password-1",
"new_password": "new-password-2"}).status_code == 401
r = c.post("/api/auth/change-password", headers=hdr, json={
"current_password": "old-password-1", "new_password": "new-password-2"})
assert r.status_code == 200 and r.json()["changed"] is True
# Old password dead, new one works, existing token still valid until expiry.
assert c.post("/api/auth/login", json={
"username": "changer", "password": "old-password-1"}).status_code == 401
assert c.post("/api/auth/login", json={
"username": "changer", "password": "new-password-2"}).status_code == 200
assert c.get("/api/auth/me", headers=hdr).status_code == 200
def test_email_code_reset_flow(clean_users, monkeypatch):
"""Emailed 6-digit code: request -> reset. Mailer mocked; email config forced on."""
from app.services import mailer
from app.api import auth as auth_module
sent: dict = {}
def fake_send(to, code, minutes):
sent["to"], sent["code"] = to, code
monkeypatch.setattr("app.config.email_enabled", lambda: True)
monkeypatch.setattr(mailer, "send_password_code", fake_send)
_add_user("coder@utopiabrands.com", "Code Person", "first-password-1")
with TestClient(app) as c:
# Unknown account: generic answer, no email, no enumeration.
r = c.post("/api/auth/request-code", json={"username": "ghost@utopiabrands.com"})
assert r.status_code == 200 and "code" not in sent
r = c.post("/api/auth/request-code", json={"username": "coder@utopiabrands.com"})
assert r.status_code == 200
assert sent["to"] == "coder@utopiabrands.com" and len(sent["code"]) == 6
# Immediate resend is throttled.
assert c.post("/api/auth/request-code",
json={"username": "coder@utopiabrands.com"}).status_code == 429
# Wrong code refused; attempts count up.
bad = "000000" if sent["code"] != "000000" else "111111"
assert c.post("/api/auth/reset-password", json={
"username": "coder@utopiabrands.com", "code": bad,
"new_password": "second-password-2"}).status_code == 400
# Right code sets the new password and is single-use.
r = c.post("/api/auth/reset-password", json={
"username": "coder@utopiabrands.com", "code": sent["code"],
"new_password": "second-password-2"})
assert r.status_code == 200 and r.json()["changed"] is True
assert c.post("/api/auth/reset-password", json={
"username": "coder@utopiabrands.com", "code": sent["code"],
"new_password": "third-password-3"}).status_code == 400
assert c.post("/api/auth/login", json={
"username": "coder@utopiabrands.com",
"password": "first-password-1"}).status_code == 401
assert c.post("/api/auth/login", json={
"username": "coder@utopiabrands.com",
"password": "second-password-2"}).status_code == 200
assert auth_module.CODE_MAX_ATTEMPTS >= 3 # sanity: lockout exists
def test_request_code_without_email_configured(clean_users, monkeypatch):
# Force the unconfigured state — the dev .env may carry real mail settings.
monkeypatch.setattr("app.config.email_enabled", lambda: False)
_add_user("noemail@utopiabrands.com", "No Email", "some-password-1")
with TestClient(app) as c:
r = c.post("/api/auth/request-code", json={"username": "noemail@utopiabrands.com"})
assert r.status_code == 503
assert "administrator" in r.json()["detail"]
def test_inactive_user_cannot_login(clean_users):
_add_user("gone", "Gone Person", "some-pw-12345")
db = SessionLocal()
try:
db.query(models.User).filter_by(username="gone").first().is_active = False
db.commit()
finally:
db.close()
auth_mod.invalidate_users_cache()
with TestClient(app) as c:
assert c.post("/api/auth/login", json={
"username": "gone", "password": "some-pw-12345"}).status_code == 401