227 lines
9.9 KiB
Python
227 lines
9.9 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
|
|
|
|
# Step-2 verify: wrong code 400, right code valid — and NOT consumed by verifying.
|
|
assert c.post("/api/auth/verify-code", json={
|
|
"username": "coder@utopiabrands.com", "code": bad}).status_code == 400
|
|
r = c.post("/api/auth/verify-code", json={
|
|
"username": "coder@utopiabrands.com", "code": sent["code"]})
|
|
assert r.status_code == 200 and r.json()["valid"] is True
|
|
|
|
# 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
|