"""Account deletion (DELETE /auth/me) and admin-forced password change (POST /admin/users/require-password-change -> POST /auth/change-password, enforced by app.auth.dependencies.get_current_user).""" from __future__ import annotations import uuid from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import AsyncSession from app.models.enums import SecurityEventType from app.repositories.user_repository import UserRepository from app.repositories.user_security_event_repository import UserSecurityEventRepository def _unique_email() -> str: return f"user-{uuid.uuid4().hex[:12]}@example.com" def _register_and_login(client: TestClient, password: str = "correct-horse-1") -> dict: email = _unique_email() client.post( "/api/v1/auth/register", json={"email": email, "password": password, "display_name": "T"}, ) tokens = client.post("/api/v1/auth/login", json={"email": email, "password": password}).json() return {"email": email, "headers": {"Authorization": f"Bearer {tokens['access_token']}"}} async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]: email = f"admin-{uuid.uuid4().hex[:12]}@example.com" client.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "Admin"}, ) user = await UserRepository(db_session).get_by_email(email) user.is_admin = True await db_session.commit() tokens = client.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ).json() return {"Authorization": f"Bearer {tokens['access_token']}"} # --- DELETE /auth/me ------------------------------------------------------- def test_delete_account_requires_auth(client: TestClient): resp = client.request( "DELETE", "/api/v1/auth/me", json={"password": "whatever"} ) assert resp.status_code == 401 def test_delete_account_rejects_wrong_password(client: TestClient): account = _register_and_login(client) resp = client.request( "DELETE", "/api/v1/auth/me", json={"password": "not-the-right-password"}, headers=account["headers"], ) assert resp.status_code == 401 def test_delete_account_succeeds_with_correct_password_and_logs_out_the_account( client: TestClient, ): account = _register_and_login(client) resp = client.request( "DELETE", "/api/v1/auth/me", json={"password": "correct-horse-1"}, headers=account["headers"], ) assert resp.status_code == 204 # The account is gone - the same token no longer resolves to anyone. me_resp = client.get("/api/v1/auth/me", headers=account["headers"]) assert me_resp.status_code == 401 # And a fresh login attempt with the same credentials fails too. login_resp = client.post( "/api/v1/auth/login", json={"email": account["email"], "password": "correct-horse-1"}, ) assert login_resp.status_code == 401 async def test_delete_account_cascades_to_owned_data(client: TestClient, db_session: AsyncSession): """A company created by the account must be gone too (ON DELETE CASCADE), not just the user row.""" account = _register_and_login(client) create_resp = client.post( "/api/v1/companies", json={"name": f"Co-{uuid.uuid4().hex[:8]}", "official_website": None}, headers=account["headers"], ) assert create_resp.status_code == 201 company_id = create_resp.json()["id"] client.request( "DELETE", "/api/v1/auth/me", json={"password": "correct-horse-1"}, headers=account["headers"] ) from sqlalchemy import text row = await db_session.execute( text("SELECT 1 FROM companies WHERE id = :id"), {"id": company_id} ) assert row.first() is None # --- POST /admin/users/require-password-change ----------------------------- def test_require_password_change_requires_admin(client: TestClient): account = _register_and_login(client) resp = client.post( "/api/v1/admin/users/require-password-change", json={"email": account["email"]}, headers=account["headers"], ) assert resp.status_code == 403 async def test_require_password_change_rejects_unknown_email( client: TestClient, db_session: AsyncSession ): headers = await _register_admin_and_login(client, db_session) resp = client.post( "/api/v1/admin/users/require-password-change", json={"email": "no-such-account@example.com"}, headers=headers, ) assert resp.status_code == 404 async def test_admin_flags_an_account_and_it_gets_logged_to_the_targets_activity( client: TestClient, db_session: AsyncSession ): """Checked directly against the DB, not via GET /auth/security-events - that endpoint isn't in the must-change-password exempt list, so the just-flagged account can't reach it until they change their password (see test_flagged_account_is_blocked_from_other_endpoints).""" admin_headers = await _register_admin_and_login(client, db_session) account = _register_and_login(client) resp = client.post( "/api/v1/admin/users/require-password-change", json={"email": account["email"]}, headers=admin_headers, ) assert resp.status_code == 204 user = await UserRepository(db_session).get_by_email(account["email"]) assert user.must_change_password is True events = await UserSecurityEventRepository(db_session).list_for_user(user.id) assert any(e.event_type == SecurityEventType.PASSWORD_CHANGE_REQUIRED for e in events) # --- Enforcement gate: everything except me/change-password/logout 403s ---- async def test_flagged_account_is_blocked_from_other_endpoints( client: TestClient, db_session: AsyncSession ): admin_headers = await _register_admin_and_login(client, db_session) account = _register_and_login(client) client.post( "/api/v1/admin/users/require-password-change", json={"email": account["email"]}, headers=admin_headers, ) blocked = client.get("/api/v1/companies", headers=account["headers"]) assert blocked.status_code == 403 still_ok = client.get("/api/v1/auth/me", headers=account["headers"]) assert still_ok.status_code == 200 assert still_ok.json()["must_change_password"] is True async def test_change_password_clears_the_flag_and_unblocks_the_account( client: TestClient, db_session: AsyncSession ): admin_headers = await _register_admin_and_login(client, db_session) account = _register_and_login(client) client.post( "/api/v1/admin/users/require-password-change", json={"email": account["email"]}, headers=admin_headers, ) change_resp = client.post( "/api/v1/auth/change-password", json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"}, headers=account["headers"], ) assert change_resp.status_code == 200 new_tokens = change_resp.json() new_headers = {"Authorization": f"Bearer {new_tokens['access_token']}"} me_resp = client.get("/api/v1/auth/me", headers=new_headers) assert me_resp.status_code == 200 assert me_resp.json()["must_change_password"] is False unblocked = client.get("/api/v1/companies", headers=new_headers) assert unblocked.status_code == 200 # The new password actually works on a fresh login. login_resp = client.post( "/api/v1/auth/login", json={"email": account["email"], "password": "brand-new-horse-2"}, ) assert login_resp.status_code == 200 def test_change_password_rejects_wrong_current_password(client: TestClient): account = _register_and_login(client) resp = client.post( "/api/v1/auth/change-password", json={"current_password": "totally-wrong", "new_password": "brand-new-horse-2"}, headers=account["headers"], ) assert resp.status_code == 401 def test_change_password_rejects_reusing_the_current_password(client: TestClient): account = _register_and_login(client) resp = client.post( "/api/v1/auth/change-password", json={"current_password": "correct-horse-1", "new_password": "correct-horse-1"}, headers=account["headers"], ) assert resp.status_code == 400 def test_change_password_revokes_the_old_refresh_token(client: TestClient): email = _unique_email() client.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) tokens = client.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ).json() headers = {"Authorization": f"Bearer {tokens['access_token']}"} client.post( "/api/v1/auth/change-password", json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"}, headers=headers, ) stale_refresh_resp = client.post( "/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]} ) assert stale_refresh_resp.status_code == 401