test_password_age.py
1 """Password-age warning tests. 2 3 Covers the soft "your password is N days old" notice in the login 4 response. The feature is opt-in (`password_max_age_days` setting, 5 default 0 = disabled) and never blocks authentication — verifying the 6 mechanism here is enough; we don't have to exercise actual aging since 7 we can write a backdated `password_updated_at` directly. 8 """ 9 from __future__ import annotations 10 11 import json 12 import random 13 from datetime import datetime, timedelta, timezone 14 15 import pytest 16 from fastapi.testclient import TestClient 17 18 from restai.config import RESTAI_DEFAULT_PASSWORD 19 from restai.database import get_db_wrapper 20 from restai.main import app 21 from restai.models.databasemodels import UserDatabase 22 23 ADMIN = ("admin", RESTAI_DEFAULT_PASSWORD) 24 25 26 @pytest.fixture(scope="module") 27 def client(): 28 with TestClient(app) as c: 29 yield c 30 31 32 @pytest.fixture(scope="module") 33 def stale_user(client): 34 """Create a user whose password is older than any reasonable 35 threshold. Returns (username, password).""" 36 suffix = str(random.randint(0, 1_000_000)) 37 username = f"pwage_user_{suffix}" 38 password = "first_pwd_123" 39 40 r = client.post("/users", json={"username": username, "password": password}, auth=ADMIN) 41 assert r.status_code in (200, 201), r.text 42 43 # Backdate password_updated_at to something definitely-stale. 44 db = get_db_wrapper() 45 try: 46 u = db.db.query(UserDatabase).filter(UserDatabase.username == username).first() 47 assert u is not None 48 u.password_updated_at = datetime.now(timezone.utc) - timedelta(days=400) 49 db.db.commit() 50 finally: 51 db.db.close() 52 yield username, password 53 client.delete(f"/users/{username}", auth=ADMIN) 54 55 56 def _set_max_age(client, days: int): 57 # TestClient keeps cookies across requests; a previous /auth/login 58 # from a non-admin test user would shadow the Basic-auth header. 59 client.cookies.clear() 60 r = client.patch("/settings", json={"password_max_age_days": days}, auth=ADMIN) 61 assert r.status_code == 200, f"PATCH /settings failed: {r.status_code} {r.text}" 62 63 64 def _reset_login_rate_limit(): 65 """The test client uses one IP — without this, ~5 logins blow the 66 `_LOGIN_MAX_ATTEMPTS=10` cap and subsequent tests in the module 429.""" 67 from restai.models.databasemodels import LoginAttemptDatabase 68 db = get_db_wrapper() 69 try: 70 db.db.query(LoginAttemptDatabase).delete() 71 db.db.commit() 72 finally: 73 db.db.close() 74 75 76 def test_login_no_warning_when_disabled(client, stale_user): 77 """Default (0) means no warning even for ancient passwords.""" 78 username, password = stale_user 79 _set_max_age(client, 0) 80 _reset_login_rate_limit() 81 client.cookies.clear() 82 r = client.post("/auth/login", auth=(username, password)) 83 assert r.status_code == 200 84 body = r.json() 85 assert "password_warning" not in body, body 86 87 88 def test_login_warning_when_password_stale(client, stale_user): 89 """With max_age=30 days and a 400-day-old password, the response 90 must include a password_warning block.""" 91 username, password = stale_user 92 _set_max_age(client, 30) 93 try: 94 _reset_login_rate_limit() 95 r = client.post("/auth/login", auth=(username, password)) 96 assert r.status_code == 200 97 body = r.json() 98 assert "password_warning" in body, body 99 warn = body["password_warning"] 100 assert warn["password_max_age_days"] == 30 101 assert warn["password_age_days"] >= 30 102 assert "change it" in warn["message"].lower() 103 finally: 104 _set_max_age(client, 0) 105 106 107 def test_login_no_warning_when_password_fresh(client): 108 """Admin's password is freshly created (or recently rotated). Setting 109 a tight max_age must NOT warn unless we know the password is older.""" 110 _set_max_age(client, 1) 111 try: 112 # admin user has password_updated_at=NULL after the migration 113 # (legacy row pre-tracking). NULL means "unknown — don't warn", 114 # which is the right default. 115 _reset_login_rate_limit() 116 r = client.post("/auth/login", auth=ADMIN) 117 assert r.status_code == 200 118 body = r.json() 119 assert "password_warning" not in body, body 120 finally: 121 _set_max_age(client, 0) 122 123 124 def test_password_change_resets_timestamp(client, stale_user): 125 """After update_user with a new password, password_updated_at must 126 be 'now' again, so the warning goes away on next login.""" 127 username, password = stale_user 128 new_password = "second_pwd_456" 129 r = client.patch(f"/users/{username}", json={"password": new_password}, auth=ADMIN) 130 assert r.status_code in (200, 201) 131 132 db = get_db_wrapper() 133 try: 134 u = db.db.query(UserDatabase).filter(UserDatabase.username == username).first() 135 assert u.password_updated_at is not None 136 # Should be within the last few seconds. 137 last = u.password_updated_at 138 if last.tzinfo is None: 139 last = last.replace(tzinfo=timezone.utc) 140 delta = datetime.now(timezone.utc) - last 141 assert delta.total_seconds() < 30, f"timestamp not recent: {delta}" 142 finally: 143 db.db.close() 144 145 # The new password works; the old one shouldn't (sanity check). 146 _reset_login_rate_limit() 147 client.cookies.clear() 148 r = client.post("/auth/login", auth=(username, new_password)) 149 assert r.status_code == 200 150 _reset_login_rate_limit() 151 client.cookies.clear() 152 r = client.post("/auth/login", auth=(username, password)) 153 assert r.status_code == 401