"""Self-service password change: prove the old password, keep this session, drop every other one.""" import pytest from httpx import AsyncClient from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.account import PERSONAL_BLUEPRINT from app.models import ( AuthSession, Document, DocumentStatus, DocumentVisibility, Template, User, ) pytestmark = pytest.mark.usefixtures("fake_llm") async def _login(client: AsyncClient, password: str = "secret123") -> None: response = await client.post( "/api/auth/login", json={"email": "pablo@test.dev", "password": password} ) assert response.status_code == 200 async def _session_count(db: AsyncSession, user_id) -> int: """Takes the id, not the ORM object: callers expire the session first, and a detached attribute access would need lazy IO.""" return ( await db.execute( select(func.count(AuthSession.id)).where(AuthSession.user_id == user_id) ) ).scalar_one() async def test_change_password_keeps_this_session_and_drops_the_others( client: AsyncClient, db: AsyncSession, seeded_user: User ) -> None: user_id = seeded_user.id # A second device: log in twice, then change the password on the second. await _login(client) await client.post("/api/auth/logout") # keeps the row count honest below await _login(client) other = await client.post( "/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"} ) assert other.status_code == 200 assert await _session_count(db, user_id) >= 2 changed = await client.post( "/api/account/password", json={"current_password": "secret123", "new_password": "neues-geheimnis"}, ) assert changed.status_code == 204 # The caller stays signed in... assert (await client.get("/api/auth/me")).status_code == 200 # ...and is now the only session left. db.expire_all() assert await _session_count(db, user_id) == 1 # The new password works, the old one does not. await client.post("/api/auth/logout") await _login(client, "neues-geheimnis") await client.post("/api/auth/logout") rejected = await client.post( "/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"} ) assert rejected.status_code == 401 async def test_wrong_current_password_changes_nothing( client: AsyncClient, db: AsyncSession, seeded_user: User ) -> None: user_id = seeded_user.id await _login(client) before = await _session_count(db, user_id) response = await client.post( "/api/account/password", json={"current_password": "falsch", "new_password": "neues-geheimnis"}, ) assert response.status_code == 403 assert response.json()["code"] == "invalid_current_password" db.expire_all() assert await _session_count(db, user_id) == before assert (await client.get("/api/auth/me")).status_code == 200 async def test_short_passwords_are_rejected( client: AsyncClient, seeded_user: User ) -> None: await _login(client) response = await client.post( "/api/account/password", json={"current_password": "secret123", "new_password": "kurz"}, ) assert response.status_code == 422 async def test_password_change_requires_a_session(client: AsyncClient) -> None: response = await client.post( "/api/account/password", json={"current_password": "secret123", "new_password": "neues-geheimnis"}, ) assert response.status_code == 401 async def test_locale_is_pinned_and_cleared( client: AsyncClient, db: AsyncSession, seeded_user: User ) -> None: """A pinned language follows the person to every device, so it rides on the user row rather than in browser storage.""" await _login(client) assert (await client.get("/api/auth/me")).json()["locale"] is None assert ( await client.put("/api/account/locale", json={"locale": "de"}) ).status_code == 204 assert (await client.get("/api/auth/me")).json()["locale"] == "de" # It survives a new session. await client.post("/api/auth/logout") await _login(client) assert (await client.get("/api/auth/me")).json()["locale"] == "de" # null puts it back to following the browser. assert ( await client.put("/api/account/locale", json={"locale": None}) ).status_code == 204 assert (await client.get("/api/auth/me")).json()["locale"] is None async def test_unsupported_locale_is_rejected( client: AsyncClient, seeded_user: User ) -> None: await _login(client) assert ( await client.put("/api/account/locale", json={"locale": "fr"}) ).status_code == 422 async def test_setting_a_locale_requires_a_session(client: AsyncClient) -> None: response = await client.put("/api/account/locale", json={"locale": "de"}) assert response.status_code == 401 async def test_the_personal_document_says_what_to_start_from( client: AsyncClient, db: AsyncSession, seeded_user: User ) -> None: """Nothing written yet: the profile page gets the blueprint to start from, and no document.""" template = Template( name="Onboarding", version="1.0", config={"id": PERSONAL_BLUEPRINT, "name": "Onboarding"}, ) db.add(template) await db.commit() await _login(client) body = (await client.get("/api/account/document")).json() assert body["document_id"] is None assert body["template_id"] == str(template.id) async def test_the_personal_document_is_found_once_written( client: AsyncClient, db: AsyncSession, seeded_user: User ) -> None: """Authorship is the whole rule: your own document from the person blueprint, never one someone else wrote.""" mine = Document( title="Onboarding: Pablo", status=DocumentStatus.draft, visibility=DocumentVisibility.department, content_md="## Rolle", meta={"template": PERSONAL_BLUEPRINT}, author_id=seeded_user.id, ) other = Document( title="Onboarding: jemand anders", status=DocumentStatus.published, visibility=DocumentVisibility.public, content_md="## Rolle", meta={"template": PERSONAL_BLUEPRINT}, author_id=None, ) db.add_all([mine, other]) await db.commit() await _login(client) body = (await client.get("/api/account/document")).json() assert body["document_id"] == str(mine.id) assert body["title"] == "Onboarding: Pablo" assert body["status"] == "draft" async def test_the_personal_document_requires_a_session(client: AsyncClient) -> None: assert (await client.get("/api/account/document")).status_code == 401