"""Account CRUD API (SPEC ยง9.1) driven through the ASGI app.""" from __future__ import annotations import uuid from collections.abc import AsyncIterator from dataclasses import dataclass import httpx import pytest_asyncio from httpx import ASGITransport from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app import queue as app_queue from app.api import accounts as accounts_api from app.config import get_settings from app.identity.passwords import hash_password from app.identity.sessions import create_session from app.main import create_app from app.models import Account, User CSRF = "test-csrf-token" GOOD_CFG = { "imap": {"host": "imap.example.com", "port": 993, "tls": "ssl"}, "smtp": {"host": "smtp.example.com", "port": 465, "tls": "ssl"}, } @dataclass class Ctx: client: httpx.AsyncClient user_id: uuid.UUID session: AsyncSession def _hdr() -> dict[str, str]: return {"X-CSRF": CSRF} @pytest_asyncio.fixture async def ctx() -> AsyncIterator[Ctx]: engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() user = User(handle=f"acc{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")) session.add(user) await session.commit() token = await create_session(user.id, "pytest") transport = ASGITransport(app=create_app()) client = httpx.AsyncClient( transport=transport, base_url="http://testserver", cookies={"session": token, "csrf": CSRF}, ) try: yield Ctx(client=client, user_id=user.id, session=session) finally: await client.aclose() await session.execute(delete(User).where(User.id == user.id)) await session.commit() await session.close() await engine.dispose() async def test_discover_gmail_preset(ctx: Ctx) -> None: resp = await ctx.client.post( "/api/accounts/discover", json={"email": "someone@gmail.com"}, headers=_hdr() ) assert resp.status_code == 200 data = resp.json() assert data["candidates"][0]["imap"]["host"] == "imap.gmail.com" assert data["candidates"][0]["source"] == "preset" assert data["steps"][0]["method"] == "preset" async def test_discover_requires_csrf(ctx: Ctx) -> None: resp = await ctx.client.post( "/api/accounts/discover", json={"email": "someone@gmail.com"} ) # no X-CSRF header assert resp.status_code == 403 assert resp.json()["error"]["code"] == "csrf_failed" async def test_discover_rate_limited(ctx: Ctx) -> None: last = None for _ in range(11): last = await ctx.client.post( "/api/accounts/discover", json={"email": "x@gmail.com"}, headers=_hdr() ) assert last is not None and last.status_code == 429 async def test_test_endpoint_maps_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] from app.accounts.testing import AccountTestError async def fake_test(**_kw: object) -> None: raise AccountTestError("auth_failed", "bad password") monkeypatch.setattr(accounts_api, "test_account", fake_test) resp = await ctx.client.post( "/api/accounts/test", json={**GOOD_CFG, "email": "me@example.com", "password": "x"}, headers=_hdr(), ) assert resp.status_code == 400 assert resp.json()["error"]["code"] == "auth_failed" async def test_create_account_enqueues_sync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] async def fake_test(**_kw: object) -> None: return None enqueued: list[tuple[str, str]] = [] class FakePool: async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None: enqueued.append((name, arg)) async def fake_pool() -> FakePool: return FakePool() monkeypatch.setattr(accounts_api, "test_account", fake_test) monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool) resp = await ctx.client.post( "/api/accounts", json={ **GOOD_CFG, "email": "me@example.com", "display_name": "Me", "color": "#ff0000", "password": "secret", }, headers=_hdr(), ) assert resp.status_code == 200, resp.text body = resp.json() assert body["email"] == "me@example.com" assert body["state"] == "backfilling" account = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(body["id"]))) assert account is not None assert account.secret_ct # credentials stored encrypted, non-empty assert account.imap_config["host"] == "imap.example.com" assert enqueued == [("sync_bootstrap_account", body["id"])] async def _make_account(ctx: Ctx, monkeypatch) -> str: # type: ignore[no-untyped-def] async def fake_test(**_kw: object) -> None: return None class FakePool: async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None: return None async def fake_pool() -> FakePool: return FakePool() monkeypatch.setattr(accounts_api, "test_account", fake_test) monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool) resp = await ctx.client.post( "/api/accounts", json={**GOOD_CFG, "email": "me@example.com", "display_name": "Me", "password": "secret"}, headers=_hdr(), ) assert resp.status_code == 200, resp.text return str(resp.json()["id"]) async def test_sync_all_enqueues_refresh_per_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] aid = await _make_account(ctx, monkeypatch) enqueued: list[tuple[str, str]] = [] class FakePool: async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None: enqueued.append((name, arg)) async def fake_pool() -> FakePool: return FakePool() monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool) resp = await ctx.client.post("/api/accounts/sync", headers=_hdr()) assert resp.status_code == 200 assert enqueued == [("refresh_account_job", aid)] async def test_update_display_and_color_no_resync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] aid = await _make_account(ctx, monkeypatch) resp = await ctx.client.patch( f"/api/accounts/{aid}", json={"display_name": "New Name", "color": "#00ff00"}, headers=_hdr(), ) assert resp.status_code == 200, resp.text body = resp.json() assert body["display_name"] == "New Name" and body["color"] == "#00ff00" async def test_update_signature(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] aid = await _make_account(ctx, monkeypatch) resp = await ctx.client.patch( f"/api/accounts/{aid}", json={"signature": "

Best,
Me

"}, headers=_hdr() ) assert resp.status_code == 200 assert resp.json()["signature"] == "

Best,
Me

" # The accounts list also carries the signature (used by the composer). listing = await ctx.client.get("/api/accounts") acct = next(a for a in listing.json() if a["id"] == aid) assert acct["signature"] == "

Best,
Me

" async def test_update_password_reencrypts_and_resyncs(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] aid = await _make_account(ctx, monkeypatch) acct = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(aid))) assert acct is not None await ctx.session.refresh(acct) old_secret = bytes(acct.secret_ct) enqueued: list[tuple[str, str]] = [] class FakePool: async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None: enqueued.append((name, arg)) async def fake_pool() -> FakePool: return FakePool() async def fake_test(**_kw: object) -> None: return None monkeypatch.setattr(accounts_api, "test_account", fake_test) monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool) resp = await ctx.client.patch( f"/api/accounts/{aid}", json={"password": "newsecret"}, headers=_hdr() ) assert resp.status_code == 200, resp.text assert resp.json()["state"] == "backfilling" await ctx.session.refresh(acct) assert bytes(acct.secret_ct) != old_secret # re-encrypted assert enqueued == [("sync_bootstrap_account", aid)] async def test_update_server_change_requires_password(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] aid = await _make_account(ctx, monkeypatch) resp = await ctx.client.patch( f"/api/accounts/{aid}", json={"imap": {"host": "imap2.example.com", "port": 993, "tls": "ssl"}}, headers=_hdr(), ) assert resp.status_code == 400 assert resp.json()["error"]["code"] == "password_required" async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] async def fake_test(**_kw: object) -> None: return None class FakePool: async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None: return None async def fake_pool() -> FakePool: return FakePool() monkeypatch.setattr(accounts_api, "test_account", fake_test) monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool) created = await ctx.client.post( "/api/accounts", json={**GOOD_CFG, "email": "del@example.com", "password": "secret"}, headers=_hdr(), ) account_id = created.json()["id"] resp = await ctx.client.request("DELETE", f"/api/accounts/{account_id}", headers=_hdr()) assert resp.status_code == 200 gone = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(account_id))) assert gone is None