"""Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password. Also covers M4-T06: two-factor login regression tests (TOTP not enabled → same as before; TOTP enabled → two-step protocol). """ from __future__ import annotations from pathlib import Path import pyotp import pytest from alembic import command from alembic.config import Config from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session from app.db import reset_db_caches from app.config import get_settings from app.main import create_app from app.services.login_throttle import N_FREE # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _api_login(client: TestClient, *, username: str = "admin", password: str = "test-password"): """Log in via POST /api/auth/login and return the response.""" return client.post( "/api/auth/login", json={"username": username, "password": password}, ) # --------------------------------------------------------------------------- # GET /api/session — unauthenticated # --------------------------------------------------------------------------- def test_get_session_unauthenticated_returns_401(client: TestClient) -> None: response = client.get("/api/session") assert response.status_code == 401 # --------------------------------------------------------------------------- # GET /api/session — authenticated (via Jinja login) # --------------------------------------------------------------------------- def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) -> None: _api_login(client) response = client.get("/api/session") assert response.status_code == 200 body = response.json() assert "user" in body assert "csrf_token" in body assert body["user"]["username"] == "admin" assert isinstance(body["user"]["force_password_change"], bool) assert isinstance(body["csrf_token"], str) assert body["csrf_token"] # non-empty def test_get_session_does_not_leak_password(client: TestClient) -> None: _api_login(client) response = client.get("/api/session") body_str = str(response.json()) assert "test-password" not in body_str assert "password_hash" not in body_str # --------------------------------------------------------------------------- # POST /api/auth/login # --------------------------------------------------------------------------- def test_post_login_valid_credentials_returns_200_with_session(client: TestClient) -> None: response = _api_login(client) assert response.status_code == 200 body = response.json() assert "user" in body assert "csrf_token" in body assert body["user"]["username"] == "admin" assert isinstance(body["csrf_token"], str) assert body["csrf_token"] def test_post_login_sets_httponly_session_cookie(client: TestClient) -> None: response = _api_login(client) assert response.status_code == 200 set_cookie = response.headers.get("set-cookie", "").lower() assert "home_automation_session=" in set_cookie assert "httponly" in set_cookie assert "samesite=lax" in set_cookie assert "path=/" in set_cookie def test_post_login_cookie_secure_flag_follows_settings(client: TestClient) -> None: """In test mode AUTH_COOKIE_SECURE_OVERRIDE=false so secure should be absent.""" response = _api_login(client) assert response.status_code == 200 set_cookie = response.headers.get("set-cookie", "").lower() # secure is absent because AUTH_COOKIE_SECURE_OVERRIDE=false in conftest assert "secure" not in set_cookie def test_post_login_invalid_credentials_returns_401(client: TestClient) -> None: response = _api_login(client, password="wrong-password") assert response.status_code == 401 # No session cookie should be set assert "set-cookie" not in response.headers or ( "home_automation_session=" not in response.headers.get("set-cookie", "").lower() ) def test_post_login_unknown_user_returns_401(client: TestClient) -> None: response = _api_login(client, username="nobody", password="irrelevant") assert response.status_code == 401 def test_post_login_does_not_require_csrf_header(client: TestClient) -> None: """Login is unauthenticated; no X-CSRF-Token should be required.""" response = client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, # Deliberately omit X-CSRF-Token ) assert response.status_code == 200 def test_post_login_allows_subsequent_authenticated_request(client: TestClient) -> None: login_resp = _api_login(client) assert login_resp.status_code == 200 # GET /api/session should now succeed (cookie was set on the client) session_resp = client.get("/api/session") assert session_resp.status_code == 200 # --------------------------------------------------------------------------- # POST /api/auth/logout # --------------------------------------------------------------------------- def test_post_logout_unauthenticated_returns_401(client: TestClient) -> None: response = client.post("/api/auth/logout", headers={"X-CSRF-Token": "token"}) assert response.status_code == 401 def test_post_logout_authenticated_missing_csrf_returns_403(client: TestClient) -> None: _api_login(client) response = client.post("/api/auth/logout") assert response.status_code == 403 def test_post_logout_authenticated_empty_csrf_returns_403(client: TestClient) -> None: _api_login(client) response = client.post("/api/auth/logout", headers={"X-CSRF-Token": ""}) assert response.status_code == 403 def test_post_logout_authenticated_with_csrf_returns_204(client: TestClient) -> None: _api_login(client) response = client.post("/api/auth/logout", headers={"X-CSRF-Token": "any-non-empty-value"}) assert response.status_code == 204 def test_post_logout_invalidates_session(client: TestClient) -> None: _api_login(client) # Verify session is active assert client.get("/api/session").status_code == 200 # Logout client.post("/api/auth/logout", headers={"X-CSRF-Token": "token"}) # Session should now be gone assert client.get("/api/session").status_code == 401 # --------------------------------------------------------------------------- # POST /api/auth/password # --------------------------------------------------------------------------- def test_post_password_unauthenticated_returns_401(client: TestClient) -> None: response = client.post( "/api/auth/password", json={ "current_password": "test-password", "new_password": "new-password-123", "confirm_password": "new-password-123", }, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 401 def test_post_password_authenticated_missing_csrf_returns_403(client: TestClient) -> None: _api_login(client) response = client.post( "/api/auth/password", json={ "current_password": "test-password", "new_password": "new-password-123", "confirm_password": "new-password-123", }, ) assert response.status_code == 403 def test_post_password_success_returns_204(client: TestClient) -> None: _api_login(client) response = client.post( "/api/auth/password", json={ "current_password": "test-password", "new_password": "new-password-123", "confirm_password": "new-password-123", }, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 204 def test_post_password_wrong_current_password_returns_400(client: TestClient) -> None: _api_login(client) response = client.post( "/api/auth/password", json={ "current_password": "wrong-current", "new_password": "new-password-123", "confirm_password": "new-password-123", }, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 400 # Error message must be generic — no leaking which check failed detail = response.json().get("detail", "") assert "current password is invalid" not in detail assert detail == "password change failed" def test_post_password_mismatched_new_passwords_returns_400(client: TestClient) -> None: _api_login(client) response = client.post( "/api/auth/password", json={ "current_password": "test-password", "new_password": "new-password-123", "confirm_password": "different-password-123", }, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 400 assert response.json()["detail"] == "password change failed" def test_post_password_too_short_returns_400(client: TestClient) -> None: _api_login(client) response = client.post( "/api/auth/password", json={ "current_password": "test-password", "new_password": "short", "confirm_password": "short", }, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 400 assert response.json()["detail"] == "password change failed" def test_post_password_same_as_current_returns_400(client: TestClient) -> None: _api_login(client) response = client.post( "/api/auth/password", json={ "current_password": "test-password", "new_password": "test-password", "confirm_password": "test-password", }, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 400 assert response.json()["detail"] == "password change failed" def test_post_password_success_sets_force_password_change_false(client: TestClient) -> None: """After successful password change, force_password_change should be False.""" _api_login(client) # The bootstrap user always has force_password_change=True; change it resp = client.post( "/api/auth/password", json={ "current_password": "test-password", "new_password": "new-password-123", "confirm_password": "new-password-123", }, headers={"X-CSRF-Token": "token"}, ) assert resp.status_code == 204 # Session still active; force_password_change should now be False session_resp = client.get("/api/session") assert session_resp.status_code == 200 assert session_resp.json()["user"]["force_password_change"] is False def test_post_password_does_not_revoke_session(client: TestClient) -> None: """After password change, the session remains valid (not revoked).""" _api_login(client) client.post( "/api/auth/password", json={ "current_password": "test-password", "new_password": "new-password-123", "confirm_password": "new-password-123", }, headers={"X-CSRF-Token": "token"}, ) # Session must still be active assert client.get("/api/session").status_code == 200 # --------------------------------------------------------------------------- # Response schema correctness — no secrets in session response # --------------------------------------------------------------------------- def test_session_response_has_no_secret_fields(client: TestClient) -> None: login_resp = _api_login(client) assert login_resp.status_code == 200 body = login_resp.json() # Must have exactly these top-level keys assert set(body.keys()) == {"user", "csrf_token"} # user must have exactly these keys assert set(body["user"].keys()) == {"username", "force_password_change"} # --------------------------------------------------------------------------- # M4-T06 — Two-factor login (TOTP second step) # Fixtures & helpers # --------------------------------------------------------------------------- def _make_app_alembic_config(database_url: str) -> Config: cfg = Config("alembic_app.ini") cfg.set_main_option("sqlalchemy.url", database_url) return cfg @pytest.fixture def totp_login_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Full client with TOTP-capable DB (throttle disabled for simplicity).""" db_path = tmp_path / "totp_login_test.db" db_url = f"sqlite:///{db_path}" monkeypatch.setenv("APP_DATABASE_URL", db_url) monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false") monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "false") get_settings.cache_clear() reset_db_caches() command.upgrade(_make_app_alembic_config(db_url), "head") reset_db_caches() app = create_app() with TestClient(app) as tc: yield tc get_settings.cache_clear() reset_db_caches() @pytest.fixture def totp_login_throttle_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Full client with TOTP + throttle ENABLED (for throttle-interaction tests).""" db_path = tmp_path / "totp_throttle_test.db" db_url = f"sqlite:///{db_path}" monkeypatch.setenv("APP_DATABASE_URL", db_url) monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false") monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "true") get_settings.cache_clear() reset_db_caches() command.upgrade(_make_app_alembic_config(db_url), "head") reset_db_caches() engine = create_engine(db_url, connect_args={"check_same_thread": False}) app = create_app() with TestClient(app) as tc: yield tc, engine engine.dispose() get_settings.cache_clear() reset_db_caches() def _enable_totp(client: TestClient) -> tuple[str, list[str]]: """Log in, setup TOTP, enable it; return (secret, recovery_codes).""" login_resp = client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert login_resp.status_code == 200, login_resp.text csrf = login_resp.json()["csrf_token"] setup_resp = client.post("/api/auth/totp/setup", headers={"X-CSRF-Token": csrf}) assert setup_resp.status_code == 200, setup_resp.text setup_body = setup_resp.json() secret = setup_body["secret"] recovery_codes = setup_body["recovery_codes"] enable_resp = client.post( "/api/auth/totp/enable", json={"code": pyotp.TOTP(secret).now()}, headers={"X-CSRF-Token": csrf}, ) assert enable_resp.status_code == 204, enable_resp.text # Log out so subsequent login tests start fresh client.post("/api/auth/logout", headers={"X-CSRF-Token": csrf}) return secret, recovery_codes # --------------------------------------------------------------------------- # M4-T06 — Regression: TOTP not enabled → login unchanged # --------------------------------------------------------------------------- class TestLoginWithoutTotpEnabled: """TOTP not enabled: login behaves exactly as before T06.""" def test_login_without_totp_code_succeeds(self, totp_login_client: TestClient) -> None: """Standard login (no totp_code field at all) still works when TOTP is off.""" resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert resp.status_code == 200 body = resp.json() assert "user" in body assert "csrf_token" in body def test_login_with_null_totp_code_succeeds(self, totp_login_client: TestClient) -> None: """Explicitly sending totp_code=null works fine when TOTP is off.""" resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": None}, ) assert resp.status_code == 200 def test_login_sets_session_cookie(self, totp_login_client: TestClient) -> None: resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert resp.status_code == 200 assert "home_automation_session=" in resp.headers.get("set-cookie", "").lower() def test_wrong_password_still_401(self, totp_login_client: TestClient) -> None: resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"}, ) assert resp.status_code == 401 # Must be the standard generic message assert resp.json()["detail"] == "invalid username or password" def test_wrong_password_no_cookie(self, totp_login_client: TestClient) -> None: resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"}, ) assert resp.status_code == 401 assert "home_automation_session=" not in resp.headers.get("set-cookie", "").lower() # --------------------------------------------------------------------------- # M4-T06 — TOTP enabled: two-step protocol # --------------------------------------------------------------------------- class TestLoginWithTotpEnabled: """TOTP enabled: missing/wrong/correct code semantics.""" def test_missing_totp_code_returns_401_with_totp_required( self, totp_login_client: TestClient ) -> None: """Correct password but no totp_code → 401 with totp_required signal.""" _enable_totp(totp_login_client) resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert resp.status_code == 401 body = resp.json() # detail must be a dict with totp_required=true assert isinstance(body["detail"], dict) assert body["detail"].get("totp_required") is True def test_totp_required_response_has_no_session_cookie( self, totp_login_client: TestClient ) -> None: """totp_required 401 must NOT set a session cookie.""" _enable_totp(totp_login_client) resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert resp.status_code == 401 assert "home_automation_session=" not in resp.headers.get("set-cookie", "").lower() def test_wrong_totp_code_returns_generic_401( self, totp_login_client: TestClient ) -> None: """Wrong TOTP code → generic 401 (no totp_required signal).""" _enable_totp(totp_login_client) resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": "000000"}, ) assert resp.status_code == 401 detail = resp.json()["detail"] # Must be a plain string (not dict with totp_required) assert isinstance(detail, str) assert detail == "invalid username or password" def test_wrong_totp_code_no_cookie(self, totp_login_client: TestClient) -> None: """Wrong TOTP → no cookie.""" _enable_totp(totp_login_client) resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": "000000"}, ) assert resp.status_code == 401 assert "home_automation_session=" not in resp.headers.get("set-cookie", "").lower() def test_correct_totp_code_issues_session(self, totp_login_client: TestClient) -> None: """Correct TOTP code → 200 + cookie + csrf.""" secret, _ = _enable_totp(totp_login_client) resp = totp_login_client.post( "/api/auth/login", json={ "username": "admin", "password": "test-password", "totp_code": pyotp.TOTP(secret).now(), }, ) assert resp.status_code == 200 body = resp.json() assert "user" in body assert "csrf_token" in body assert "home_automation_session=" in resp.headers.get("set-cookie", "").lower() def test_totp_required_detail_not_on_wrong_password( self, totp_login_client: TestClient ) -> None: """Wrong password must NOT carry totp_required (cannot reveal TOTP state).""" _enable_totp(totp_login_client) resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "wrong-password"}, ) assert resp.status_code == 401 detail = resp.json()["detail"] assert isinstance(detail, str), "Wrong-password 401 must not expose totp_required" if isinstance(detail, dict): assert "totp_required" not in detail # --------------------------------------------------------------------------- # M4-T06 — Recovery codes can be used for login (one-time) # --------------------------------------------------------------------------- class TestRecoveryCodeLogin: """Recovery codes work as second factor and are consumed after use.""" def test_recovery_code_login_succeeds(self, totp_login_client: TestClient) -> None: _, recovery_codes = _enable_totp(totp_login_client) code = recovery_codes[0] resp = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": code}, ) assert resp.status_code == 200, resp.text def test_recovery_code_is_one_time_only(self, totp_login_client: TestClient) -> None: """Using the same recovery code twice: second use must fail (401).""" _, recovery_codes = _enable_totp(totp_login_client) code = recovery_codes[0] # First use: success resp1 = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": code}, ) assert resp1.status_code == 200, resp1.text # Log out csrf = resp1.json()["csrf_token"] totp_login_client.post("/api/auth/logout", headers={"X-CSRF-Token": csrf}) # Second use: must fail resp2 = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": code}, ) assert resp2.status_code == 401, ( "Recovery code must be rejected on second use" ) def test_recovery_code_used_at_set_in_db(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """After consuming a recovery code, its used_at is set in the DB.""" from app.models.auth import RecoveryCode from sqlalchemy import select db_path = tmp_path / "rc_used_test.db" db_url = f"sqlite:///{db_path}" monkeypatch.setenv("APP_DATABASE_URL", db_url) monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false") monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "false") get_settings.cache_clear() reset_db_caches() command.upgrade(_make_app_alembic_config(db_url), "head") reset_db_caches() engine = create_engine(db_url, connect_args={"check_same_thread": False}) app = create_app() with TestClient(app) as tc: secret, recovery_codes = _enable_totp(tc) code = recovery_codes[0] tc.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": code}, ) with Session(engine) as db: used_codes = db.execute( select(RecoveryCode).where(RecoveryCode.used_at.isnot(None)) ).scalars().all() assert len(used_codes) >= 1, "At least one recovery code should have used_at set" engine.dispose() get_settings.cache_clear() reset_db_caches() def test_other_recovery_codes_still_work_after_one_consumed( self, totp_login_client: TestClient ) -> None: """Consuming one recovery code leaves the others intact.""" _, recovery_codes = _enable_totp(totp_login_client) # Use first code resp1 = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": recovery_codes[0]}, ) assert resp1.status_code == 200 csrf = resp1.json()["csrf_token"] totp_login_client.post("/api/auth/logout", headers={"X-CSRF-Token": csrf}) # Second code must still work resp2 = totp_login_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": recovery_codes[1]}, ) assert resp2.status_code == 200, ( "Second recovery code should still be valid after first is consumed" ) # --------------------------------------------------------------------------- # M4-T06 — Throttle interaction with TOTP second step # --------------------------------------------------------------------------- class TestThrottleWithTotpEnabled: """Verify throttle semantics when TOTP is enabled: - Missing code (totp_required) does NOT count as a throttle failure. - Wrong TOTP/recovery code DOES count as a throttle failure. - Throttle check at step 1 still gates step 2. """ def test_missing_code_does_not_register_throttle_failure( self, totp_login_throttle_client ) -> None: """Sending correct password without totp_code must not count as a failure. If this test sends N_FREE+1 such requests without accumulating throttle failures, the next request will still get 401 (totp_required) not 429. """ client, engine = totp_login_throttle_client _enable_totp(client) # Send N_FREE + 2 requests with correct password but no totp_code. for i in range(N_FREE + 2): resp = client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) # Must never become 429 — each is a legitimate first-step assert resp.status_code == 401, ( f"Iteration {i}: expected 401 (totp_required), got {resp.status_code}" ) body = resp.json() assert isinstance(body["detail"], dict) assert body["detail"].get("totp_required") is True def test_wrong_totp_code_accumulates_throttle_failures( self, totp_login_throttle_client ) -> None: """Wrong TOTP codes should eventually trigger 429.""" client, engine = totp_login_throttle_client _enable_totp(client) # Send N_FREE + 1 wrong-code requests — each should register a failure for _ in range(N_FREE + 1): client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": "000000"}, ) # Next attempt (even with correct password + no code) should hit 429 resp = client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert resp.status_code == 429, ( "After N_FREE+1 wrong TOTP codes the throttle should kick in" ) def test_throttle_blocks_step_one_when_in_window( self, totp_login_throttle_client ) -> None: """Once throttled (by wrong codes), even step-1 attempt is rejected 429.""" client, engine = totp_login_throttle_client _enable_totp(client) # Accumulate failures via wrong TOTP codes for _ in range(N_FREE + 1): client.post( "/api/auth/login", json={"username": "admin", "password": "test-password", "totp_code": "000000"}, ) # Throttle check is before password — even wrong password should get 429 resp = client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"}, ) assert resp.status_code == 429