import httpx import pytest from src import main from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None): return { "id": draft_id, "title": title, "body": "Steps from the field", "saved_at": 1_723_600_000_000, "blockers": [{"repository": "stackchain/api", "number": 7, "title": "API rollout"}], "evidence": (evidence if evidence is not None else [ { "filename": "checkout.png", "content_type": "image/png", "note": "Error after tapping Pay", "data": "cG5nLWJ5dGVz", } ]), } def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path): store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3") created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")]) assert created == {"revision": 1, "drafts": [draft(), draft("second", title="Second")]} assert store.get("timmy") == created assert store.get("alexander") == {"revision": 0, "drafts": []} with pytest.raises(UnfiledDraftConflict) as conflict: store.replace("timmy", 0, [draft(title="Stale overwrite")]) assert conflict.value.snapshot == created def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path): store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12) with pytest.raises(ValueError, match="limited to 2"): store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])]) with pytest.raises(ValueError, match="evidence is too large"): store.replace("timmy", 0, [draft(evidence=[{ "filename": "large.png", "content_type": "image/png", "data": "eHh4eHh4eHh4eHh4eHh4eHh4eHg=", }])]) with pytest.raises(ValueError, match="valid base64"): store.replace("timmy", 0, [draft(evidence=[{ "filename": "bad.png", "content_type": "image/png", "data": "not base64!", }])]) @pytest.mark.anyio async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") monkeypatch.setenv( "STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy", ) monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3")) monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3")) identity = {"login": "Timmy"} async def user(): return {"id": 1, "login": identity["login"]} monkeypatch.setattr(main, "current_user", user) transport = httpx.ASGITransport(app=main.app) payload = {"revision": 0, "drafts": [draft(evidence=[])]} async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"}) forbidden = await client.put("/api/v1/unfiled-drafts", json=payload) headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]} saved = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers) stale = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers) fetched = await client.get("/api/v1/unfiled-drafts") identity["login"] = "Alexander" isolated = await client.get("/api/v1/unfiled-drafts") assert forbidden.status_code == 403 assert saved.status_code == 200 expected = draft(evidence=[]) expected.pop("evidence") assert saved.json() == {"revision": 1, "drafts": [expected]} assert stale.status_code == 409 assert stale.json()["detail"]["snapshot"] == saved.json() assert fetched.json() == saved.json() assert fetched.headers["cache-control"] == "no-store" assert isolated.json() == {"revision": 0, "drafts": []}