"""Tests for authentication router (/api/auth).""" import pytest from httpx import AsyncClient from src.backend.tests.conftest import _create_user, auth_headers class TestLogin: """POST /api/auth/login tests.""" async def test_login_success(self, client: AsyncClient, db_session): """Successful login returns user data and API key.""" user = await _create_user( db_session, username="loginuser", password="SecurePass1" ) resp = await client.post( "/api/auth/login", json={"username": "loginuser", "password": "SecurePass1"}, ) assert resp.status_code == 200 data = resp.json() assert data["user"]["username"] == "loginuser" assert "api_key" in data assert len(data["api_key"]) > 0 async def test_login_wrong_password(self, client: AsyncClient, db_session): """Login with wrong password returns 401.""" await _create_user( db_session, username="wrongpw", password="CorrectPass1" ) resp = await client.post( "/api/auth/login", json={"username": "wrongpw", "password": "WrongPass1"}, ) assert resp.status_code == 401 assert "Invalid" in resp.json()["detail"] async def test_login_unknown_user(self, client: AsyncClient): """Login with non-existent username returns 401.""" resp = await client.post( "/api/auth/login", json={"username": "nonexistent", "password": "Anything1"}, ) assert resp.status_code == 401 async def test_login_inactive_user(self, client: AsyncClient, db_session): """Login with inactive user returns 401.""" await _create_user( db_session, username="inactive_user", password="InactivePass1", active=False, ) resp = await client.post( "/api/auth/login", json={"username": "inactive_user", "password": "InactivePass1"}, ) assert resp.status_code == 401 async def test_login_missing_fields(self, client: AsyncClient): """Login with missing fields returns 422.""" resp = await client.post("/api/auth/login", json={"username": "only"}) assert resp.status_code == 422 async def test_login_returns_api_key(self, client: AsyncClient, db_session): """Login response contains a valid api_key string.""" await _create_user( db_session, username="apikey_user", password="KeyPass123" ) resp = await client.post( "/api/auth/login", json={"username": "apikey_user", "password": "KeyPass123"}, ) assert resp.status_code == 200 api_key = resp.json()["api_key"] # API key should be a non-empty string (base64url, ~64 chars) assert isinstance(api_key, str) assert len(api_key) >= 32 class TestLoginKeyReuse: """login_user reuses an existing api_key instead of rotating it (#25).""" async def test_two_logins_same_user_share_api_key( self, client: AsyncClient, db_session ): """Two stations logging in as the same shift account both keep working.""" await _create_user(db_session, username="twostations", password="TwoStations1") resp1 = await client.post( "/api/auth/login", json={"username": "twostations", "password": "TwoStations1"}, ) resp2 = await client.post( "/api/auth/login", json={"username": "twostations", "password": "TwoStations1"}, ) assert resp1.status_code == 200 assert resp2.status_code == 200 assert resp1.json()["api_key"] == resp2.json()["api_key"] async def test_login_generates_key_when_user_has_none( self, client: AsyncClient, db_session ): """A user created without an api_key (e.g. via create_user) still gets one.""" from src.backend.services.auth_service import create_user await create_user( db_session, username="brandnew", password="BrandNew1", display_name="Brand New", ) resp = await client.post( "/api/auth/login", json={"username": "brandnew", "password": "BrandNew1"}, ) assert resp.status_code == 200 assert resp.json()["api_key"] class TestLogout: """POST /api/auth/logout is a no-op on the shared api_key by default (#25).""" async def test_logout_default_keeps_api_key_valid( self, client: AsyncClient, db_session ): user = await _create_user( db_session, username="shared_logout", password="SharedPass1" ) headers = auth_headers(user) resp = await client.post("/api/auth/logout", headers=headers) assert resp.status_code == 204 # The key is still valid: another station on the same account keeps working. resp = await client.get("/api/auth/me", headers=headers) assert resp.status_code == 200 async def test_logout_all_sessions_invalidates_api_key( self, client: AsyncClient, db_session ): user = await _create_user( db_session, username="shared_logout_all", password="SharedPass1" ) headers = auth_headers(user) resp = await client.post( "/api/auth/logout", params={"all_sessions": "true"}, headers=headers ) assert resp.status_code == 204 resp = await client.get("/api/auth/me", headers=headers) assert resp.status_code == 401 class TestAuthenticateUserTiming: """authenticate_user pays the bcrypt cost even for an unknown username (#25).""" async def test_unknown_username_still_returns_none(self, db_session): from src.backend.services.auth_service import authenticate_user result = await authenticate_user(db_session, "no-such-user-at-all", "whatever") assert result is None def test_dummy_hash_is_a_valid_bcrypt_hash(self): from src.backend.services.auth_service import ( _DUMMY_PASSWORD_HASH, verify_password, ) assert verify_password( "tiemeasureflow-dummy-hash-for-timing", _DUMMY_PASSWORD_HASH ) class TestHealthAndAuth: """Health check and auth requirement tests.""" async def test_health_check_no_auth(self, client: AsyncClient): """Health check endpoint works without authentication.""" resp = await client.get("/api/health") assert resp.status_code == 200 data = resp.json() assert data["status"] == "ok" assert "version" in data async def test_endpoint_requires_api_key(self, client: AsyncClient): """Protected endpoint returns 401 without API key.""" resp = await client.get("/api/users") assert resp.status_code == 401 assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]