- config: WORKER_ROLE (sync|jobs|all), INTERACTIVE_CONCURRENCY, BULK_CONCURRENCY. - queue: named queues (interactive/bulk) + enqueue_job() router; sync_bootstrap goes to bulk, all other jobs to interactive. - workers.run: single entrypoint; role=jobs runs both arq consumers (per-queue timeout/concurrency), role=sync runs the supervisor, all=both (dev default). - docker-compose: worker-sync + worker-jobs services (shared image, nofile ulimit); backend/Dockerfile; Makefile worker/worker-sync/worker-jobs targets. - tests repointed to app.queue.get_arq_pool; supervisor behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
284 lines
9.7 KiB
Python
284 lines
9.7 KiB
Python
"""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": "<p>Best,<br>Me</p>"}, headers=_hdr()
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["signature"] == "<p>Best,<br>Me</p>"
|
|
# 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"] == "<p>Best,<br>Me</p>"
|
|
|
|
|
|
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
|