security: encrypt synchronized unfiled drafts (Closes #1098)
This commit is contained in:
parent
19a1184b1c
commit
d3f53b5a38
13
README.md
13
README.md
|
|
@ -191,8 +191,10 @@ cannot alter a newer crash-recovery claim. Device purge cancels an active drain
|
||||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
|
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
|
||||||
filesystem boundary independently of the service umask: the database directory is repaired to
|
filesystem boundary independently of the service umask: the database directory is repaired to
|
||||||
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
||||||
paths are rejected before access. This protects state from unrelated local accounts, but it is not
|
paths are rejected before access. Synchronized unfiled Draft collections add AES-256-GCM payload
|
||||||
encryption at rest; secure host access, encrypted volumes, and private backups are still required.
|
encryption with account and revision authentication; existing plaintext rows migrate on their first
|
||||||
|
read without changing revision or order. Other private stores are not encrypted at the application
|
||||||
|
layer, so secure host access, encrypted volumes, and private backups are still required.
|
||||||
Set `STACKCHAIN_STATE_DIR` to a
|
Set `STACKCHAIN_STATE_DIR` to a
|
||||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||||
|
|
@ -258,6 +260,13 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||||
|
# Required for cross-device unfiled Draft sync. Inject the base64 encoding of
|
||||||
|
# exactly 32 random bytes from a secret manager; never commit the value.
|
||||||
|
# Generate once, for example: openssl rand -base64 32
|
||||||
|
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||||
|
# Keep this key stable and back it up separately from the database. There is no
|
||||||
|
# online key rotation: replacing or losing it makes existing encrypted Drafts
|
||||||
|
# unavailable. Restore the prior key to recover them before planning a rotation.
|
||||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||||
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
fastapi==0.133.1
|
fastapi==0.133.1
|
||||||
|
cryptography==50.0.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
Pillow==12.3.0
|
Pillow==12.3.0
|
||||||
|
|
|
||||||
16
src/main.py
16
src/main.py
|
|
@ -60,7 +60,12 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||||
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
from src.unfiled_draft_store import (
|
||||||
|
UnfiledDraftConflict,
|
||||||
|
UnfiledDraftEncryptionError,
|
||||||
|
UnfiledDraftStore,
|
||||||
|
decode_unfiled_draft_encryption_key,
|
||||||
|
)
|
||||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
from src.later_store import LaterStore
|
from src.later_store import LaterStore
|
||||||
|
|
@ -2285,7 +2290,10 @@ def _completed_filed_review_store() -> CompletedFiledReviewStore:
|
||||||
|
|
||||||
def _unfiled_draft_store() -> UnfiledDraftStore:
|
def _unfiled_draft_store() -> UnfiledDraftStore:
|
||||||
return UnfiledDraftStore(
|
return UnfiledDraftStore(
|
||||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")),
|
||||||
|
encryption_key=decode_unfiled_draft_encryption_key(
|
||||||
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", "")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2386,7 +2394,7 @@ async def get_unfiled_drafts(response: Response):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
try:
|
try:
|
||||||
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
|
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
|
||||||
except (OSError, sqlite3.Error):
|
except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Draft synchronization is unavailable",
|
detail="Draft synchronization is unavailable",
|
||||||
|
|
@ -2416,7 +2424,7 @@ async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc))
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
except (OSError, sqlite3.Error):
|
except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Draft synchronization is unavailable",
|
detail="Draft synchronization is unavailable",
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,15 @@
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
from src.private_state import connect_private_sqlite
|
from src.private_state import connect_private_sqlite
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,16 +28,41 @@ class UnfiledDraftConflict(ValueError):
|
||||||
self.snapshot = snapshot
|
self.snapshot = snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class UnfiledDraftEncryptionError(RuntimeError):
|
||||||
|
"""Raised when private draft state cannot be authenticated and decrypted."""
|
||||||
|
|
||||||
|
|
||||||
|
def decode_unfiled_draft_encryption_key(encoded: str) -> bytes:
|
||||||
|
"""Decode the independently injected 256-bit draft-encryption key."""
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(encoded, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as error:
|
||||||
|
raise UnfiledDraftEncryptionError(
|
||||||
|
"unfiled draft encryption key is invalid"
|
||||||
|
) from error
|
||||||
|
if len(key) != 32:
|
||||||
|
raise UnfiledDraftEncryptionError(
|
||||||
|
"unfiled draft encryption key must decode to exactly 32 bytes"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
class UnfiledDraftStore:
|
class UnfiledDraftStore:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
path: str | Path,
|
path: str | Path,
|
||||||
*,
|
*,
|
||||||
|
encryption_key: bytes,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
max_total_bytes: int = 12 * 1024 * 1024,
|
max_total_bytes: int = 12 * 1024 * 1024,
|
||||||
timeout: float = 1.0,
|
timeout: float = 1.0,
|
||||||
):
|
):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
|
if not isinstance(encryption_key, bytes) or len(encryption_key) != 32:
|
||||||
|
raise UnfiledDraftEncryptionError(
|
||||||
|
"unfiled draft encryption requires exactly 32 key bytes"
|
||||||
|
)
|
||||||
|
self._cipher = AESGCM(encryption_key)
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.max_total_bytes = max_total_bytes
|
self.max_total_bytes = max_total_bytes
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
@ -61,18 +90,71 @@ class UnfiledDraftStore:
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _snapshot(row) -> dict:
|
def _aad(login: str, revision: int) -> bytes:
|
||||||
return {"revision": 0, "drafts": []} if row is None else {
|
return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()
|
||||||
"revision": int(row[0]), "drafts": json.loads(row[1])
|
|
||||||
}
|
def _encrypt(self, login: str, revision: int, drafts: list[dict]) -> str:
|
||||||
|
plaintext = json.dumps(drafts, separators=(",", ":")).encode()
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
sealed = nonce + self._cipher.encrypt(
|
||||||
|
nonce, plaintext, self._aad(login, revision)
|
||||||
|
)
|
||||||
|
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||||
|
|
||||||
|
def _decrypt(self, login: str, revision: int, payload: str) -> list[dict]:
|
||||||
|
try:
|
||||||
|
sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True)
|
||||||
|
if len(sealed) < 12 + 16:
|
||||||
|
raise ValueError("encrypted payload is too short")
|
||||||
|
plaintext = self._cipher.decrypt(
|
||||||
|
sealed[:12], sealed[12:], self._aad(login, revision)
|
||||||
|
)
|
||||||
|
drafts = json.loads(plaintext)
|
||||||
|
if not isinstance(drafts, list):
|
||||||
|
raise ValueError("decrypted payload is not a collection")
|
||||||
|
return drafts
|
||||||
|
except (binascii.Error, InvalidTag, UnicodeDecodeError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
raise UnfiledDraftEncryptionError(
|
||||||
|
"unfiled drafts could not be decrypted"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def _snapshot(self, login: str, row) -> tuple[dict, bool]:
|
||||||
|
if row is None:
|
||||||
|
return {"revision": 0, "drafts": []}, False
|
||||||
|
revision, payload = int(row[0]), row[1]
|
||||||
|
if payload.startswith("v1:"):
|
||||||
|
drafts = self._decrypt(login, revision, payload)
|
||||||
|
return {"revision": revision, "drafts": drafts}, False
|
||||||
|
try:
|
||||||
|
drafts = json.loads(payload)
|
||||||
|
except (TypeError, json.JSONDecodeError) as error:
|
||||||
|
raise UnfiledDraftEncryptionError(
|
||||||
|
"legacy unfiled drafts could not be decoded"
|
||||||
|
) from error
|
||||||
|
if not isinstance(drafts, list):
|
||||||
|
raise UnfiledDraftEncryptionError(
|
||||||
|
"legacy unfiled drafts could not be decoded"
|
||||||
|
)
|
||||||
|
return {"revision": revision, "drafts": drafts}, True
|
||||||
|
|
||||||
def get(self, login: str) -> dict:
|
def get(self, login: str) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
|
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
|
||||||
(self._login(login),),
|
(login,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return self._snapshot(row)
|
snapshot, legacy = self._snapshot(login, row)
|
||||||
|
if legacy:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE unfiled_drafts SET drafts = ? WHERE login = ?",
|
||||||
|
(
|
||||||
|
self._encrypt(login, snapshot["revision"], snapshot["drafts"]),
|
||||||
|
login,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _filing_plan(raw: object) -> dict | None:
|
def _filing_plan(raw: object) -> dict | None:
|
||||||
|
|
@ -233,16 +315,16 @@ class UnfiledDraftStore:
|
||||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||||
raise ValueError("revision is invalid")
|
raise ValueError("revision is invalid")
|
||||||
normalized = self._normalize(drafts)
|
normalized = self._normalize(drafts)
|
||||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
|
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
current = self._snapshot(row)
|
current, _legacy = self._snapshot(login, row)
|
||||||
if current["revision"] != expected_revision:
|
if current["revision"] != expected_revision:
|
||||||
raise UnfiledDraftConflict(current)
|
raise UnfiledDraftConflict(current)
|
||||||
revision = expected_revision + 1
|
revision = expected_revision + 1
|
||||||
|
serialized = self._encrypt(login, revision, normalized)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
|
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
|
||||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
|
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ def test_private_sqlite_connection_rejects_a_symlink_before_opening_target(tmp_p
|
||||||
TodayStore,
|
TodayStore,
|
||||||
LaterStore,
|
LaterStore,
|
||||||
SavedSearchStore,
|
SavedSearchStore,
|
||||||
UnfiledDraftStore,
|
lambda path: UnfiledDraftStore(path, encryption_key=b"d" * 32),
|
||||||
CompletedFiledReviewStore,
|
CompletedFiledReviewStore,
|
||||||
PushSubscriptionStore,
|
PushSubscriptionStore,
|
||||||
LiveSnapshotStore,
|
LiveSnapshotStore,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import main
|
from src import main, unfiled_draft_store
|
||||||
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||||
|
|
||||||
|
|
||||||
|
ENCRYPTION_KEY = b"d" * 32
|
||||||
|
|
||||||
|
|
||||||
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
|
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
|
||||||
return {
|
return {
|
||||||
"id": draft_id,
|
"id": draft_id,
|
||||||
|
|
@ -23,8 +29,77 @@ def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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_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):
|
def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
store = UnfiledDraftStore(
|
||||||
|
tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY
|
||||||
|
)
|
||||||
|
|
||||||
created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")])
|
created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")])
|
||||||
|
|
||||||
|
|
@ -37,7 +112,12 @@ def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
|
def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
|
||||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12)
|
store = UnfiledDraftStore(
|
||||||
|
tmp_path / "unfiled.sqlite3",
|
||||||
|
encryption_key=ENCRYPTION_KEY,
|
||||||
|
limit=2,
|
||||||
|
max_total_bytes=12,
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="limited to 2"):
|
with pytest.raises(ValueError, match="limited to 2"):
|
||||||
store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])])
|
store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])])
|
||||||
|
|
@ -52,7 +132,9 @@ def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(tmp_path):
|
def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(tmp_path):
|
||||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
store = UnfiledDraftStore(
|
||||||
|
tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY
|
||||||
|
)
|
||||||
photo_only = draft(title="")
|
photo_only = draft(title="")
|
||||||
|
|
||||||
validated = main.UnfiledDraft.model_validate(photo_only).model_dump()
|
validated = main.UnfiledDraft.model_validate(photo_only).model_dump()
|
||||||
|
|
@ -66,7 +148,9 @@ def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(t
|
||||||
|
|
||||||
|
|
||||||
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
|
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
|
||||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
store = UnfiledDraftStore(
|
||||||
|
tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY
|
||||||
|
)
|
||||||
planned = draft(evidence=[])
|
planned = draft(evidence=[])
|
||||||
planned.pop("evidence")
|
planned.pop("evidence")
|
||||||
planned["filing_plan"] = {
|
planned["filing_plan"] = {
|
||||||
|
|
@ -102,6 +186,10 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s
|
||||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.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_DB", str(tmp_path / "drafts.sqlite3"))
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY",
|
||||||
|
"ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ=",
|
||||||
|
)
|
||||||
identity = {"login": "Timmy"}
|
identity = {"login": "Timmy"}
|
||||||
|
|
||||||
async def user():
|
async def user():
|
||||||
|
|
@ -133,6 +221,10 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s
|
||||||
fetched = await client.get("/api/v1/unfiled-drafts")
|
fetched = await client.get("/api/v1/unfiled-drafts")
|
||||||
identity["login"] = "Alexander"
|
identity["login"] = "Alexander"
|
||||||
isolated = await client.get("/api/v1/unfiled-drafts")
|
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 forbidden.status_code == 403
|
||||||
assert saved.status_code == 200
|
assert saved.status_code == 200
|
||||||
|
|
@ -144,3 +236,7 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s
|
||||||
assert fetched.json() == saved.json()
|
assert fetched.json() == saved.json()
|
||||||
assert fetched.headers["cache-control"] == "no-store"
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
assert isolated.json() == {"revision": 0, "drafts": []}
|
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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user