"""Auth tests — ACs 1-9: login, logout, me, switch-tenant, password reset, CSRF.""" from __future__ import annotations import pytest from httpx import AsyncClient from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest.mark.asyncio class TestAuthLogin: """ACs 1-3: Login valid, invalid, me without session.""" async def test_login_valid_returns_200_and_cookie(self, client: AsyncClient, db_session): """AC 1: POST /api/v1/auth/login valid -> 200 + Set-Cookie leocrm_session.""" await seed_tenant_and_users(db_session) resp = await client.post( "/api/v1/auth/login", json={"email": "admin@tenanta.com", "password": "TestPass123!"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert "leocrm_session" in resp.headers.get( "set-cookie", "" ).lower() or "leocrm_session" in str(resp.cookies) data = resp.json() assert data["email"] == "admin@tenanta.com" assert data["role"] == "admin" async def test_login_invalid_returns_401(self, client: AsyncClient, db_session): """AC 2: POST /api/v1/auth/login invalid -> 401.""" await seed_tenant_and_users(db_session) resp = await client.post( "/api/v1/auth/login", json={"email": "admin@tenanta.com", "password": "WrongPassword!"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 401 async def test_me_without_session_returns_401(self, client: AsyncClient): """AC 3: GET /api/v1/auth/me without session -> 401.""" resp = await client.get("/api/v1/auth/me") assert resp.status_code == 401 @pytest.mark.asyncio class TestAuthMe: """AC 4: me with valid session.""" async def test_me_with_valid_session_returns_200(self, client: AsyncClient, db_session): """AC 4: GET /api/v1/auth/me with valid session -> 200 + user+tenant JSON.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/auth/me") assert resp.status_code == 200 data = resp.json() assert data["email"] == "admin@tenanta.com" assert data["role"] == "admin" assert "tenant_id" in data assert "tenant_name" in data @pytest.mark.asyncio class TestAuthLogout: """AC 5: logout.""" async def test_logout_returns_200_and_invalidates_session( self, client: AsyncClient, db_session ): """AC 5: POST /api/v1/auth/logout -> 200, session invalidated.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Verify we're logged in resp = await client.get("/api/v1/auth/me") assert resp.status_code == 200 # Logout resp = await client.post("/api/v1/auth/logout", headers=ORIGIN_HEADER) assert resp.status_code == 200 # Verify session is invalidated resp = await client.get("/api/v1/auth/me") assert resp.status_code == 401 @pytest.mark.asyncio class TestPasswordReset: """ACs 6-8: password reset request, confirm valid, confirm expired.""" async def test_password_reset_request_always_200(self, client: AsyncClient, db_session): """AC 6: POST /api/v1/auth/password-reset/request -> always 200 (no user enumeration).""" await seed_tenant_and_users(db_session) # Existing email resp = await client.post( "/api/v1/auth/password-reset/request", json={"email": "admin@tenanta.com"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 # Non-existing email — still 200 resp = await client.post( "/api/v1/auth/password-reset/request", json={"email": "nonexistent@example.com"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 async def test_password_reset_confirm_valid_token(self, client: AsyncClient, db_session): """AC 7: POST /api/v1/auth/password-reset/confirm valid token -> 200.""" from app.services.auth_service import auth_service await seed_tenant_and_users(db_session) raw_token = await auth_service.get_password_reset_token_raw(db_session, "admin@tenanta.com") await db_session.commit() assert raw_token is not None resp = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": raw_token, "new_password": "NewPass456!"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 async def test_password_reset_confirm_expired_token(self, client: AsyncClient, db_session): """AC 8: POST /api/v1/auth/password-reset/confirm expired token -> 400.""" from app.services.auth_service import auth_service await seed_tenant_and_users(db_session) raw_token = await auth_service.create_expired_reset_token(db_session, "admin@tenanta.com") await db_session.commit() assert raw_token is not None resp = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": raw_token, "new_password": "NewPass456!"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 400 @pytest.mark.asyncio class TestSwitchTenant: """AC 9: switch-tenant.""" async def test_switch_tenant_returns_200(self, client: AsyncClient, db_session): """AC 9: POST /api/v1/auth/switch-tenant -> 200, session tenant_id updated.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Get initial tenant resp = await client.get("/api/v1/auth/me") assert resp.status_code == 200 initial_tenant = resp.json()["tenant_id"] # Get tenant B ID from sqlalchemy import select from app.models.tenant import Tenant q = select(Tenant).where(Tenant.slug == "tenant-b") result = await db_session.execute(q) tenant_b = result.scalar_one() # Switch to tenant B resp = await client.post( "/api/v1/auth/switch-tenant", json={"tenant_id": str(tenant_b.id)}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["tenant_id"] == str(tenant_b.id) assert resp.json()["tenant_id"] != initial_tenant @pytest.mark.asyncio class TestCSRF: """AC 23: CSRF — POST without Origin header -> 403.""" async def test_post_without_origin_returns_403(self, client: AsyncClient, db_session): """AC 23: POST without Origin header -> 403.""" await seed_tenant_and_users(db_session) resp = await client.post( "/api/v1/auth/login", json={"email": "admin@tenanta.com", "password": "TestPass123!"}, # No Origin header ) assert resp.status_code == 403