Spaces:
Running
Running
| """ | |
| Authentication tests: password policy, hashing migration, token lifecycle. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import secrets | |
| import pytest | |
| from tests.conftest import VALID_PASSWORD, auth_header, register_and_login | |
| # βββ Password policy (H2) ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_register_rejects_weak_password(client, password, reason): | |
| res = client.post( | |
| "/api/auth/register", | |
| json={"username": "weakuser", "display_name": "W", "password": password}, | |
| ) | |
| assert res.status_code == 422, f"accepted a password with {reason}" | |
| def test_register_accepts_strong_password(client): | |
| res = client.post( | |
| "/api/auth/register", | |
| json={"username": "stronguser", "display_name": "S", "password": VALID_PASSWORD}, | |
| ) | |
| assert res.status_code == 200 | |
| assert res.json()["success"] is True | |
| def test_passwords_are_never_stored_in_plaintext(client, temp_auth_db): | |
| register_and_login(client, "alice") | |
| from app.auth import _conn | |
| with _conn() as db: | |
| stored = db.execute( | |
| "SELECT password_hash FROM users WHERE username = 'alice'" | |
| ).fetchone()[0] | |
| assert VALID_PASSWORD not in stored | |
| assert stored.startswith("pbkdf2_sha256$"), "not using the PBKDF2 scheme" | |
| assert int(stored.split("$")[1]) >= 600_000, "iteration count below OWASP guidance" | |
| # βββ Legacy migration (H1) βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_legacy_sha256_user_logs_in_and_is_rehashed(client, temp_auth_db): | |
| """ | |
| AUDIT H1: accounts created under salted single-round SHA-256 must keep | |
| working and be silently upgraded β a migration that locks users out is not | |
| a migration. | |
| """ | |
| from app.auth import _conn, login_user | |
| from app.security import needs_rehash | |
| username, password = "legacy", "oldpassword" | |
| salt = secrets.token_hex(16) | |
| legacy_hash = hashlib.sha256((salt + password).encode()).hexdigest() | |
| with _conn() as db: | |
| db.execute( | |
| "INSERT INTO users (username, display_name, password_hash, salt) VALUES (?,?,?,?)", | |
| (username, "Legacy", legacy_hash, salt), | |
| ) | |
| db.commit() | |
| def stored() -> str: | |
| with _conn() as db: | |
| return db.execute( | |
| "SELECT password_hash FROM users WHERE username = ?", (username,) | |
| ).fetchone()[0] | |
| assert needs_rehash(stored()) | |
| ok, _, display = login_user(username, password) | |
| assert ok and display == "Legacy" | |
| upgraded = stored() | |
| assert upgraded.startswith("pbkdf2_sha256$") | |
| assert not needs_rehash(upgraded) | |
| # Still usable after the upgrade, and still rejects the wrong password. | |
| assert login_user(username, password)[0] is True | |
| assert login_user(username, "wrong")[0] is False | |
| # βββ Token lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_login_returns_token_pair(client): | |
| tokens = register_and_login(client, "alice") | |
| assert tokens["token_type"] == "bearer" | |
| assert tokens["access_token"].count(".") == 2, "access token is not a JWT" | |
| assert tokens["refresh_token"] | |
| assert tokens["expires_in"] > 0 | |
| def test_login_with_wrong_password_is_401(client): | |
| register_and_login(client, "alice") | |
| res = client.post("/api/auth/login", json={"username": "alice", "password": "Wr0ng!Passw0rd"}) | |
| assert res.status_code == 401 | |
| def test_access_token_grants_access(client): | |
| tokens = register_and_login(client, "alice") | |
| me = client.get("/api/auth/me", headers=auth_header(tokens)) | |
| assert me.status_code == 200 | |
| assert me.json()["user_id"] == "alice" | |
| def test_expired_access_token_is_rejected(client, monkeypatch): | |
| from api.settings import get_api_settings | |
| tokens = register_and_login(client, "alice") | |
| # Re-issue with a negative TTL to simulate expiry deterministically. | |
| cfg = get_api_settings() | |
| monkeypatch.setattr(cfg, "access_token_ttl_minutes", -1) | |
| from api.security import issue_access_token | |
| expired, _ = issue_access_token("alice") | |
| res = client.get("/api/auth/me", headers={"Authorization": f"Bearer {expired}"}) | |
| assert res.status_code == 401 | |
| assert "expired" in res.json()["message"].lower() | |
| # The original (valid) token still works β we only invalidated the new one. | |
| assert client.get("/api/auth/me", headers=auth_header(tokens)).status_code == 200 | |
| def test_refresh_rotates_and_detects_reuse(client): | |
| """ | |
| Rotation + theft detection: a spent refresh token must not work twice, and | |
| presenting one is treated as compromise, killing the whole family. | |
| """ | |
| tokens = register_and_login(client, "alice") | |
| original = tokens["refresh_token"] | |
| first = client.post("/api/auth/refresh", json={"refresh_token": original}) | |
| assert first.status_code == 200 | |
| rotated = first.json()["refresh_token"] | |
| assert rotated != original | |
| # Replay of the spent token β rejected. | |
| assert client.post("/api/auth/refresh", json={"refresh_token": original}).status_code == 401 | |
| # β¦and the family is revoked, so the legitimate successor dies too. | |
| assert client.post("/api/auth/refresh", json={"refresh_token": rotated}).status_code == 401 | |
| def test_logout_revokes_refresh_token(client): | |
| tokens = register_and_login(client, "alice") | |
| assert client.post( | |
| "/api/auth/logout", json={"refresh_token": tokens["refresh_token"]} | |
| ).status_code == 204 | |
| assert client.post( | |
| "/api/auth/refresh", json={"refresh_token": tokens["refresh_token"]} | |
| ).status_code == 401 | |
| def test_refresh_token_cannot_be_used_as_access_token(client): | |
| """Token type confusion: a refresh token must not authenticate a data route.""" | |
| tokens = register_and_login(client, "alice") | |
| res = client.get( | |
| "/api/auth/me", headers={"Authorization": f"Bearer {tokens['refresh_token']}"} | |
| ) | |
| assert res.status_code == 401 | |