from httpx import AsyncClient from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models import ( AuthSession, Conversation, ConversationMode, Document, DocumentStatus, Message, MessageRole, User, ) async def _login(client: AsyncClient, email: str) -> None: response = await client.post( "/api/auth/login", json={"email": email, "password": "secret123"} ) assert response.status_code == 200 async def test_users_crud_requires_admin( client: AsyncClient, seeded_user: User ) -> None: await _login(client, "pablo@test.dev") assert (await client.get("/api/admin/users")).status_code == 403 async def test_full_user_lifecycle( client: AsyncClient, db: AsyncSession, seeded_admin: User ) -> None: await _login(client, "florian@test.dev") department = ( await client.post("/api/admin/departments", json={"name": "QS"}) ).json() created = await client.post( "/api/admin/users", json={ "email": "quinn@test.dev", "name": "Quinn Test", "role": "member", "department_id": department["id"], "password": "secret123", }, ) assert created.status_code == 200 user_id = created.json()["id"] # Duplicate email → 409. duplicate = await client.post( "/api/admin/users", json={"email": "quinn@test.dev", "name": "X", "password": "secret123"}, ) assert duplicate.status_code == 409 assert duplicate.json()["code"] == "email_taken" # The new user can log in. await client.post("/api/auth/logout") await _login(client, "quinn@test.dev") me = (await client.get("/api/auth/me")).json() assert me["department_id"] == department["id"] # Password reset by the admin invalidates the old password. await client.post("/api/auth/logout") await _login(client, "florian@test.dev") reset = await client.patch( f"/api/admin/users/{user_id}", json={"password": "new-secret-1"} ) assert reset.status_code == 200 await client.post("/api/auth/logout") old_login = await client.post( "/api/auth/login", json={"email": "quinn@test.dev", "password": "secret123"} ) assert old_login.status_code == 401 new_login = await client.post( "/api/auth/login", json={"email": "quinn@test.dev", "password": "new-secret-1"} ) assert new_login.status_code == 200 # Deleting the user keeps their documents, authorless. document = Document( title="Bleibt", status=DocumentStatus.published, visibility="public", content_md="# Bleibt", author_id=user_id, ) db.add(document) await db.commit() await client.post("/api/auth/logout") await _login(client, "florian@test.dev") assert (await client.delete(f"/api/admin/users/{user_id}")).status_code == 204 db.expire_all() survivor = ( await db.execute(select(Document).where(Document.title == "Bleibt")) ).scalar_one() assert survivor.author_id is None async def test_admin_cannot_delete_or_demote_self( client: AsyncClient, seeded_admin: User ) -> None: await _login(client, "florian@test.dev") me = (await client.get("/api/auth/me")).json() delete = await client.delete(f"/api/admin/users/{me['id']}") assert delete.status_code == 409 assert delete.json()["code"] == "self_modification" demote = await client.patch(f"/api/admin/users/{me['id']}", json={"role": "member"}) assert demote.status_code == 409 async def test_department_crud_and_conflicts( client: AsyncClient, seeded_admin: User ) -> None: await _login(client, "florian@test.dev") created = ( await client.post("/api/admin/departments", json={"name": "Montage"}) ).json() conflict = await client.post("/api/admin/departments", json={"name": "Montage"}) assert conflict.status_code == 409 assert conflict.json()["code"] == "name_taken" renamed = await client.patch( f"/api/admin/departments/{created['id']}", json={"name": "Endmontage"} ) assert renamed.json()["name"] == "Endmontage" # Any authenticated user can list departments for pickers. listing = (await client.get("/api/departments")).json() assert "Endmontage" in [d["name"] for d in listing] assert ( await client.delete(f"/api/admin/departments/{created['id']}") ).status_code == 204 async def test_deleting_a_department_in_use_needs_confirmation( client: AsyncClient, seeded_admin: User ) -> None: """A department with members/documents/grants cannot be silently deleted: the CASCADE would drop its shared-access grants unseen, so it takes an explicit confirm.""" await _login(client, "florian@test.dev") dept = ( await client.post("/api/admin/departments", json={"name": "Vertrieb"}) ).json() await client.post( "/api/admin/users", json={ "email": "neu@test.dev", "name": "Neu", "role": "member", "department_id": dept["id"], "password": "secret123", }, ) blocked = await client.delete(f"/api/admin/departments/{dept['id']}") assert blocked.status_code == 409 assert blocked.json()["code"] == "department_in_use" confirmed = await client.delete( f"/api/admin/departments/{dept['id']}", params={"confirm": "true"} ) assert confirmed.status_code == 204 async def test_password_reset_revokes_all_sessions( client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User ) -> None: # Pablo logs in — her session row exists and her cookie is captured. await _login(client, "pablo@test.dev") anna_cookie = client.cookies.get("pablan_session") assert anna_cookie is not None # Admin (same client, new cookie) resets Pablo's password. await _login(client, "florian@test.dev") reset = await client.patch( f"/api/admin/users/{seeded_user.id}", json={"password": "brand-new-pw1"} ) assert reset.status_code == 200 # Pablo's old session is dead ... remaining = ( await db.execute( select(func.count(AuthSession.id)).where( AuthSession.user_id == seeded_user.id ) ) ).scalar_one() assert remaining == 0 client.cookies.clear() client.cookies.set("pablan_session", anna_cookie) assert (await client.get("/api/auth/me")).status_code == 401 # ... while the acting admin's session survived. await _login(client, "florian@test.dev") assert (await client.get("/api/auth/me")).status_code == 200 async def test_admin_changing_own_password_logs_themselves_out( client: AsyncClient, seeded_admin: User ) -> None: await _login(client, "florian@test.dev") me = (await client.get("/api/auth/me")).json() reset = await client.patch( f"/api/admin/users/{me['id']}", json={"password": "next-password-1"} ) assert reset.status_code == 200 assert (await client.get("/api/auth/me")).status_code == 401 async def test_user_delete_cascades_conversations_but_not_documents( client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User ) -> None: """GDPR offboarding: transcripts are personal data and go with the user; the approved document is the legitimate artifact and stays.""" conversation = Conversation(mode=ConversationMode.query, user_id=seeded_user.id) db.add(conversation) await db.flush() db.add( Message( conversation_id=conversation.id, role=MessageRole.user, content="personal transcript", ) ) document = Document( title="Artefakt", status=DocumentStatus.published, visibility="public", content_md="# Artefakt", author_id=seeded_user.id, ) db.add(document) await db.commit() await _login(client, "florian@test.dev") assert ( await client.delete(f"/api/admin/users/{seeded_user.id}") ).status_code == 204 db.expire_all() assert (await db.execute(select(func.count(Conversation.id)))).scalar_one() == 0 assert (await db.execute(select(func.count(Message.id)))).scalar_one() == 0 survivor = ( await db.execute(select(Document).where(Document.title == "Artefakt")) ).scalar_one() assert survivor.author_id is None async def test_admin_can_correct_a_users_email( client: AsyncClient, db: AsyncSession, seeded_admin: User ) -> None: """A typo in an address used to mean deleting the person and losing everything hanging off their id.""" await _login(client, "florian@test.dev") created = ( await client.post( "/api/admin/users", json={ "email": "tpyo@pablan.dev", "name": "Typo", "role": "member", "password": "secret123", }, ) ).json() patched = await client.patch( f"/api/admin/users/{created['id']}", json={"email": " Fixed@Pablan.DEV ", "name": "Fixed"}, ) assert patched.status_code == 200 # Normalised exactly as creation does, or login would stop finding them. assert patched.json()["email"] == "fixed@pablan.dev" assert patched.json()["name"] == "Fixed" login = await client.post( "/api/auth/login", json={"email": "fixed@pablan.dev", "password": "secret123"} ) assert login.status_code == 200 async def test_an_email_already_in_use_is_refused( client: AsyncClient, db: AsyncSession, seeded_admin: User, seeded_user: User ) -> None: await _login(client, "florian@test.dev") users = (await client.get("/api/admin/users")).json()["items"] target = next(u for u in users if u["email"] == "pablo@test.dev") clash = await client.patch( f"/api/admin/users/{target['id']}", json={"email": "florian@test.dev"} ) assert clash.status_code == 409 assert clash.json()["code"] == "email_taken" async def test_the_user_list_pages_and_searches( client: AsyncClient, db: AsyncSession, seeded_admin: User, seeded_user: User ) -> None: """The admin screen is the one place that scales with headcount.""" await _login(client, "florian@test.dev") for index in range(6): await client.post( "/api/admin/users", json={ "email": f"kolleg{index}@pablan.dev", "name": f"Kollege {index}", "role": "member", "password": "secret123", }, ) first = (await client.get("/api/admin/users", params={"per_page": 3})).json() assert len(first["items"]) == 3 assert first["total"] >= 8 second = ( await client.get("/api/admin/users", params={"per_page": 3, "page": 2}) ).json() assert {u["id"] for u in first["items"]}.isdisjoint( u["id"] for u in second["items"] ) # Search covers both columns, because an admin looking for someone knows # one of the two and rarely which. by_name = ( await client.get("/api/admin/users", params={"search": "Kollege 4"}) ).json() assert [u["email"] for u in by_name["items"]] == ["kolleg4@pablan.dev"] by_email = ( await client.get("/api/admin/users", params={"search": "kolleg4@"}) ).json() assert by_email["total"] == 1