import json import sqlite3 import httpx import pytest from src import main, unfiled_draft_store from src.unfiled_draft_store import ( UnfiledDraftConflict, UnfiledDraftStore, decode_unfiled_draft_encryption_keyring, ) ENCRYPTION_KEY = b"d" * 32 def test_unfiled_draft_keyring_configuration_decodes_named_keys(): encoded = json.dumps({ "legacy": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=", "next": "bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4=", }) keys, active = decode_unfiled_draft_encryption_keyring(encoded, "next") assert keys == {"legacy": b"o" * 32, "next": b"n" * 32} assert active == "next" @pytest.mark.parametrize( ("encoded", "active"), [ ('{"one":"b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=","one":"bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="}', "one"), (json.dumps({"bad id": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28="}), "bad id"), (json.dumps({"one": "short"}), "one"), (json.dumps({"one": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28="}), "missing"), ( json.dumps({ "one": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=", "alias": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=", }), "one", ), ], ) def test_unfiled_draft_keyring_configuration_rejects_ambiguous_or_unusable_keys( encoded, active ): with pytest.raises( unfiled_draft_store.UnfiledDraftEncryptionError, match="keyring is invalid", ): decode_unfiled_draft_encryption_keyring(encoded, active) def test_unfiled_draft_store_uses_keyring_environment(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3")) monkeypatch.delenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", raising=False) monkeypatch.setenv( "STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS", json.dumps({"old": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=", "new": "bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="}), ) monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID", "new") store = main._unfiled_draft_store() store.replace("timmy", 0, [draft()]) with sqlite3.connect(tmp_path / "drafts.sqlite3") as connection: payload = connection.execute("SELECT drafts FROM unfiled_drafts").fetchone()[0] assert payload.startswith("v2:new:") 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_encrypt_private_content_and_authenticate_the_account(tmp_path): path = tmp_path / "unfiled.sqlite3" store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY) created = store.replace("timmy", 0, [draft()]) with sqlite3.connect(path) as connection: payload = connection.execute( "SELECT drafts FROM unfiled_drafts WHERE login = 'timmy'" ).fetchone()[0] connection.execute( "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?)", ("alexander", 1, payload), ) assert payload.startswith("v1:") assert "Broken checkout" not in payload assert "Steps from the field" not in payload assert "cG5nLWJ5dGVz" not in payload assert store.get("timmy") == created with pytest.raises( unfiled_draft_store.UnfiledDraftEncryptionError, match="could not be decrypted", ): store.get("alexander") def test_unfiled_drafts_keyring_writes_with_the_named_active_key(tmp_path): path = tmp_path / "unfiled.sqlite3" store = UnfiledDraftStore( path, encryption_keys={"old": b"o" * 32, "2026-08": b"n" * 32}, active_key_id="2026-08", ) created = store.replace("timmy", 0, [draft()]) with sqlite3.connect(path) as connection: payload = connection.execute( "SELECT drafts FROM unfiled_drafts WHERE login = 'timmy'" ).fetchone()[0] assert payload.startswith("v2:2026-08:") assert "Broken checkout" not in payload assert store.get("timmy") == created def test_unfiled_drafts_keyring_rewraps_v1_without_advancing_revision(tmp_path): path = tmp_path / "unfiled.sqlite3" old_store = UnfiledDraftStore(path, encryption_key=b"o" * 32) created = old_store.replace("timmy", 0, [draft()]) rotating_store = UnfiledDraftStore( path, encryption_keys={"legacy": b"o" * 32, "new": b"n" * 32}, active_key_id="new", ) assert rotating_store.get("timmy") == created with sqlite3.connect(path) as connection: revision, payload = connection.execute( "SELECT revision, drafts FROM unfiled_drafts WHERE login = 'timmy'" ).fetchone() assert revision == 1 assert payload.startswith("v2:new:") def test_unfiled_drafts_keyring_rewraps_a_named_inactive_key(tmp_path): path = tmp_path / "unfiled.sqlite3" old_store = UnfiledDraftStore( path, encryption_keys={"old": b"o" * 32}, active_key_id="old" ) created = old_store.replace("timmy", 0, [draft()]) rotating_store = UnfiledDraftStore( path, encryption_keys={"old": b"o" * 32, "new": b"n" * 32}, active_key_id="new", ) assert rotating_store.get("timmy") == created with sqlite3.connect(path) as connection: revision, payload = connection.execute( "SELECT revision, drafts FROM unfiled_drafts WHERE login = 'timmy'" ).fetchone() assert revision == 1 assert payload.startswith("v2:new:") def test_unfiled_drafts_keyring_authenticates_the_envelope_key_id(tmp_path): path = tmp_path / "unfiled.sqlite3" store = UnfiledDraftStore( path, encryption_keys={"old": b"o" * 32, "new": b"n" * 32}, active_key_id="new", ) store.replace("timmy", 0, [draft()]) with sqlite3.connect(path) as connection: payload = connection.execute("SELECT drafts FROM unfiled_drafts").fetchone()[0] connection.execute( "UPDATE unfiled_drafts SET drafts = ?", (payload.replace("v2:new:", "v2:old:", 1),), ) with pytest.raises( unfiled_draft_store.UnfiledDraftEncryptionError, match="could not be decrypted", ): store.get("timmy") def test_unfiled_drafts_rewrap_all_reports_content_free_aggregate(tmp_path): path = tmp_path / "unfiled.sqlite3" old_store = UnfiledDraftStore(path, encryption_key=b"o" * 32) old_store.replace("timmy", 0, [draft()]) old_store.replace("alexander", 0, [draft(title="Private roadmap")]) rotating_store = UnfiledDraftStore( path, encryption_keys={"legacy": b"o" * 32, "new": b"n" * 32}, active_key_id="new", ) result = rotating_store.rewrap_all() assert result == {"total": 2, "migrated": 2, "current": 0, "failed": 0} assert "timmy" not in json.dumps(result) assert "Private roadmap" not in json.dumps(result) assert rotating_store.rewrap_all() == { "total": 2, "migrated": 0, "current": 2, "failed": 0, } def test_unfiled_drafts_migrate_plaintext_without_changing_revision_or_order(tmp_path): path = tmp_path / "unfiled.sqlite3" store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY) legacy = [draft(), draft("second", title="Second")] with sqlite3.connect(path) as connection: connection.execute( "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?)", ("timmy", 7, json.dumps(legacy, separators=(",", ":"))), ) assert store.get("timmy") == {"revision": 7, "drafts": legacy} with sqlite3.connect(path) as connection: migrated = connection.execute( "SELECT revision, drafts FROM unfiled_drafts WHERE login = 'timmy'" ).fetchone() assert migrated[0] == 7 assert migrated[1].startswith("v1:") assert "Broken checkout" not in migrated[1] def test_unfiled_drafts_reject_tampered_ciphertext(tmp_path): path = tmp_path / "unfiled.sqlite3" store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY) store.replace("timmy", 0, [draft()]) with sqlite3.connect(path) as connection: payload = connection.execute( "SELECT drafts FROM unfiled_drafts WHERE login = 'timmy'" ).fetchone()[0] replacement = "A" if payload[-1] != "A" else "B" connection.execute( "UPDATE unfiled_drafts SET drafts = ? WHERE login = 'timmy'", (payload[:-1] + replacement,), ) with pytest.raises( unfiled_draft_store.UnfiledDraftEncryptionError, match="could not be decrypted", ): store.get("timmy") def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path): store = UnfiledDraftStore( tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY ) 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", encryption_key=ENCRYPTION_KEY, 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!", }])]) def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(tmp_path): store = UnfiledDraftStore( tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY ) photo_only = draft(title="") validated = main.UnfiledDraft.model_validate(photo_only).model_dump() assert store.replace("timmy", 0, [validated])["drafts"][0]["title"] == "" empty = draft(title="", evidence=[]) with pytest.raises(ValueError, match="title or evidence is required"): main.UnfiledDraft.model_validate(empty) with pytest.raises(ValueError, match="title or evidence is required"): store.replace("timmy", 1, [empty]) def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path): store = UnfiledDraftStore( tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY ) planned = draft(evidence=[]) planned.pop("evidence") planned["filing_plan"] = { "repository": "stackchain/dashboard", "label_ids": [7, 3], "milestone_id": 12, "due_date": "2026-08-21", "template_name": "Bug report", "template_id": "bug.yml", "captured_body": "Original field notes", "assignee": "alexander", "assignee_name": "Alexander", "estimate_minutes": 45, "completion_intent": "create-and-start", } assert store.replace("timmy", 0, [planned])["drafts"] == [planned] invalid = draft(evidence=[]) invalid["filing_plan"] = {**planned["filing_plan"], "repository": "not-a-repository"} with pytest.raises(ValueError, match="filing repository is invalid"): store.replace("timmy", 1, [invalid]) @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")) monkeypatch.setenv( "STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", "ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ=", ) identity = {"login": "Timmy"} async def user(): return {"id": 1, "login": identity["login"]} monkeypatch.setattr(main, "current_user", user) transport = httpx.ASGITransport(app=main.app) planned = draft(evidence=[]) planned["filing_plan"] = { "repository": "stackchain/dashboard", "label_ids": [7, 3], "milestone_id": 12, "due_date": "2026-08-21", "template_name": "Bug report", "template_id": "bug.yml", "captured_body": "Original field notes", "assignee": "alexander", "assignee_name": "Alexander", "estimate_minutes": 45, "completion_intent": "create-and-start", } payload = {"revision": 0, "drafts": [planned]} 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") monkeypatch.delenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY") unavailable = await client.get("/api/v1/unfiled-drafts") monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", "not-base64") malformed = await client.get("/api/v1/unfiled-drafts") assert forbidden.status_code == 403 assert saved.status_code == 200 expected = planned.copy() 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": []} assert unavailable.status_code == 503 assert unavailable.json()["detail"] == "Draft synchronization is unavailable" assert malformed.status_code == 503 assert malformed.json()["detail"] == "Draft synchronization is unavailable"