Compare commits
No commits in common. "8cd1d1f684ee1ec1ebdf53770b8ec562d9247ab4" and "19a1184b1c32dbe500cb4d14faa508d33f359d3e" have entirely different histories.
8cd1d1f684
...
19a1184b1c
13
README.md
13
README.md
|
|
@ -191,10 +191,8 @@ 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. Synchronized unfiled Draft collections add AES-256-GCM payload
|
paths are rejected before access. This protects state from unrelated local accounts, but it is not
|
||||||
encryption with account and revision authentication; existing plaintext rows migrate on their first
|
encryption at rest; secure host access, encrypted volumes, and private backups are still required.
|
||||||
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
|
||||||
|
|
@ -260,13 +258,6 @@ 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,5 +1,4 @@
|
||||||
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,12 +60,7 @@ 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 (
|
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||||
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
|
||||||
|
|
@ -2290,10 +2285,7 @@ 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", "")
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2394,7 +2386,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, UnfiledDraftEncryptionError):
|
except (OSError, sqlite3.Error):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Draft synchronization is unavailable",
|
detail="Draft synchronization is unavailable",
|
||||||
|
|
@ -2424,7 +2416,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, UnfiledDraftEncryptionError):
|
except (OSError, sqlite3.Error):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Draft synchronization is unavailable",
|
detail="Draft synchronization is unavailable",
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,11 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -28,41 +24,16 @@ 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
|
||||||
|
|
@ -90,71 +61,18 @@ class UnfiledDraftStore:
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _aad(login: str, revision: int) -> bytes:
|
def _snapshot(row) -> dict:
|
||||||
return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()
|
return {"revision": 0, "drafts": []} if row is None else {
|
||||||
|
"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 = ?",
|
||||||
(login,),
|
(self._login(login),),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
snapshot, legacy = self._snapshot(login, row)
|
return self._snapshot(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:
|
||||||
|
|
@ -315,16 +233,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, _legacy = self._snapshot(login, row)
|
current = self._snapshot(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,9 +84,6 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
||||||
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
|
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
|
||||||
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
|
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
|
||||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
|
||||||
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY": (
|
|
||||||
"ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ="
|
|
||||||
),
|
|
||||||
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
|
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
|
||||||
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
|
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ def test_private_sqlite_connection_rejects_a_symlink_before_opening_target(tmp_p
|
||||||
TodayStore,
|
TodayStore,
|
||||||
LaterStore,
|
LaterStore,
|
||||||
SavedSearchStore,
|
SavedSearchStore,
|
||||||
lambda path: UnfiledDraftStore(path, encryption_key=b"d" * 32),
|
UnfiledDraftStore,
|
||||||
CompletedFiledReviewStore,
|
CompletedFiledReviewStore,
|
||||||
PushSubscriptionStore,
|
PushSubscriptionStore,
|
||||||
LiveSnapshotStore,
|
LiveSnapshotStore,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import main, unfiled_draft_store
|
from src import main
|
||||||
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,
|
||||||
|
|
@ -29,77 +23,8 @@ 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(
|
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||||
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")])
|
||||||
|
|
||||||
|
|
@ -112,12 +37,7 @@ 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(
|
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12)
|
||||||
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=[])])
|
||||||
|
|
@ -132,9 +52,7 @@ 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(
|
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||||
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()
|
||||||
|
|
@ -148,9 +66,7 @@ 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(
|
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||||
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"] = {
|
||||||
|
|
@ -186,10 +102,6 @@ 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():
|
||||||
|
|
@ -221,10 +133,6 @@ 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
|
||||||
|
|
@ -236,7 +144,3 @@ 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