/ tests / test_password_age.py
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