security: encrypt synchronized Today state (Closes #1112)
This commit is contained in:
1 parent
c219f130c8
commit
db3ca83f29
3 files changed
+380
-91
No files matched your search
@@ -4,9 +4,115 @@ import pytest
|
||||
import httpx
|
||||
|
||||
from src import main
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
|
||||
|
||||
|
||||
def test_task_bearing_today_journey_is_encrypted_at_rest_and_survives_restart(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
key = b"t" * 32
|
||||
store = TodayStore(path, encryption_key=key, clock=lambda: 1234.5)
|
||||
issue = "issue:private-roadmap/launch:4242:"
|
||||
device = "private-phone-canary"
|
||||
recap_session = "private-recap-canary"
|
||||
|
||||
plan = store.apply_batch("Timmy", [
|
||||
{"operation_id": "add", "action": "add", "item_id": issue},
|
||||
{"operation_id": "configure", "action": "configure", "item_id": "plan",
|
||||
"capacity_minutes": 180, "estimates": {issue: 75}},
|
||||
])
|
||||
session = store.update_session(
|
||||
"timmy", base_revision=0, device_id=device, identity=issue,
|
||||
elapsed_ms=90_000, running=True,
|
||||
)
|
||||
recap = store.save_recap("timmy", recap_session, [
|
||||
{"identity": issue, "estimate_minutes": 75, "actual_minutes": 91}
|
||||
])
|
||||
assert store.begin_time_log("timmy", recap_session, issue, 91) == "claimed"
|
||||
store.finish_time_log("timmy", recap_session, issue, succeeded=True)
|
||||
|
||||
retained = b"".join(
|
||||
candidate.read_bytes()
|
||||
for candidate in (path, path.with_name(path.name + "-wal"))
|
||||
if candidate.exists()
|
||||
)
|
||||
for canary in (issue, device, recap_session):
|
||||
assert canary.encode() not in retained
|
||||
assert retained.count(b"v1:") >= 4
|
||||
|
||||
reopened = TodayStore(path, encryption_key=key)
|
||||
assert reopened.get("timmy") == {
|
||||
key: value for key, value in plan.items()
|
||||
if key not in {"accepted_operation_ids", "duplicate_operation_ids", "rejected_operations"}
|
||||
}
|
||||
assert reopened.get_session("timmy") == session
|
||||
assert reopened.list_recaps("timmy") == [recap]
|
||||
assert reopened.begin_time_log("timmy", recap_session, issue, 91) == "logged"
|
||||
|
||||
|
||||
def test_legacy_today_rows_migrate_on_use_without_changing_behavior(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
key = b"m" * 32
|
||||
store = TodayStore(path, encryption_key=key)
|
||||
issue = "issue:legacy-private/project:7:"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
("timmy", 4, f'["{issue}"]', 120, f'{{"{issue}":45}}', "2026-08-19", "UTC"),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("timmy", 3, "legacy-phone", issue, 5000, 1, None, 99.0),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_recaps VALUES (?, ?, ?, ?)",
|
||||
("timmy", "legacy-session", 88.0,
|
||||
f'[{ {"identity": issue, "estimate_minutes": 45, "actual_minutes": 50} }]'.replace("'", '"')),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_time_logs VALUES (?, ?, ?, ?, ?)",
|
||||
("timmy", "legacy-session", issue, 50, "pending"),
|
||||
)
|
||||
|
||||
assert store.get("timmy")["revision"] == 4
|
||||
assert store.get_session("timmy")["revision"] == 3
|
||||
assert store.list_recaps("timmy")[0]["items"][0]["identity"] == issue
|
||||
assert store.begin_time_log("timmy", "legacy-session", issue, 50) == "pending"
|
||||
|
||||
retained = path.read_bytes()
|
||||
for canary in (issue, "legacy-phone", "legacy-session"):
|
||||
assert canary.encode() not in retained
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tampered_or_account_substituted_today_state_fails_closed_with_retry(monkeypatch, tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, encryption_key=b"a" * 32)
|
||||
store.apply("timmy", "seed", "add", "issue:private/repo:9:")
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT ids FROM today_plans WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, estimates) VALUES ('alexander', 1, ?, '{}')",
|
||||
(payload,),
|
||||
)
|
||||
|
||||
with pytest.raises(PrivateStateEncryptionError):
|
||||
store.get("alexander")
|
||||
|
||||
async def user():
|
||||
return {"login": "alexander"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.get_today_plan()
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.detail == "Today synchronization is unavailable"
|
||||
assert raised.value.headers == {"Retry-After": "1"}
|
||||
|
||||
|
||||
def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
|
||||
Reference in new issue
Block a user