"""SP15 — SQLCipher key rotation API endpoint tests. We test the *wiring* of the endpoint: 1. Refuses with 400 when encryption is not enabled. 2. Refuses with 409 when a rotation is already in flight. 3. On success: calls rotate_db_key, updates the Keychain, rebuilds the engine, writes an audit event, and returns the fingerprints. 4. On Keychain write failure: returns 503 (DB is rotated, Keychain is stale; operator must restore). The actual ``PRAGMA rekey`` mechanics are tested in ``test_db_crypto.py`` (see :class:`TestRotateDbKey`); we don't duplicate that here. """ from __future__ import annotations from datetime import datetime, timezone from pathlib import Path from unittest.mock import patch import pytest # Skip if sqlcipher3 isn't installed. pytestmark = pytest.mark.skipif( not __import__( "cyclone.db_crypto", fromlist=["is_sqlcipher_available"] ).is_sqlcipher_available(), reason="sqlcipher3 not installed", ) def _stub_rotate_ok(*, url, old_key, new_key) -> dict: """Return a synthetic RotateKeyResult for endpoint wiring tests.""" from cyclone.db_crypto import RotateKeyResult return RotateKeyResult( ok=True, old_fingerprint="aaaa1111", new_fingerprint="bbbb2222", rotated_at=datetime.now(timezone.utc).isoformat(), table_count=12, ) class TestRotateKeyRefusesWhenNotEncrypted: def test_400_when_encryption_disabled(self, tmp_path, monkeypatch): from cyclone import db, db_crypto monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/plain.db") db._reset_for_tests() monkeypatch.setattr(db_crypto, "get_secret", lambda account: None) db.init_db() from fastapi.testclient import TestClient from cyclone.api import app with TestClient(app) as client: r = client.post("/api/admin/db/rotate-key") assert r.status_code == 400 assert "not enabled" in r.json()["detail"] db._reset_for_tests() class TestRotateKeyEndpointWiring: @pytest.fixture def _fake_encrypted_env(self, tmp_path, monkeypatch): """Set up: encryption-enabled DB on disk, fake Keychain (read + write), and the engine initialized here. With NullPool (see ``cyclone.db._make_engine``), every thread opens its own SQLCipher connection — no cross-thread reuse, no ProgramingError. The endpoint runs on the request thread and verification runs on the test thread; both get fresh per-thread connections transparently. """ from cyclone import db, db_crypto db_file = tmp_path / "cyclone.db" monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{db_file}") db._reset_for_tests() fake_kc = {db_crypto.KEYCHAIN_ACCOUNT: "old-test-key-1"} monkeypatch.setattr(db_crypto, "get_secret", lambda n: fake_kc.get(n)) monkeypatch.setattr("cyclone.secrets.get_secret", lambda n: fake_kc.get(n)) monkeypatch.setattr("cyclone.secrets.set_secret", lambda n, v: fake_kc.__setitem__(n, v) or True) # The endpoint's actual rekey is stubbed; the real PRAGMA # rekey mechanics are tested in test_db_crypto.py::TestRotateDbKey. monkeypatch.setattr("cyclone.api._db_crypto.rotate_db_key", _stub_rotate_ok) db.init_db() yield db_file, fake_kc db._reset_for_tests() def test_successful_rotation_updates_keychain_and_writes_audit( self, _fake_encrypted_env, ): from cyclone import db # The fixture stubs rotate_db_key to a no-op success. from fastapi.testclient import TestClient from cyclone.api import app with TestClient(app) as client: r = client.post( "/api/admin/db/rotate-key", json={"actor": "alice", "reason": "scheduled"}, ) assert r.status_code == 200, r.text body = r.json() assert body["ok"] is True assert body["old_fingerprint"] == "aaaa1111" assert body["new_fingerprint"] == "bbbb2222" assert body["table_count"] == 12 def test_successful_rotation_writes_audit_event( self, _fake_encrypted_env, ): from cyclone import db import json as _json from fastapi.testclient import TestClient from cyclone.api import app with TestClient(app) as client: r = client.post("/api/admin/db/rotate-key", json={"actor": "bob"}) assert r.status_code == 200 from cyclone.db import AuditLog with db.SessionLocal()() as session: events = ( session.query(AuditLog) .filter(AuditLog.event_type == "db.key_rotated") .all() ) assert len(events) == 1 e = events[0] assert e.entity_type == "database" assert e.entity_id == "cyclone.db" assert e.actor == "bob" payload = _json.loads(e.payload_json) assert payload["old_fingerprint"] == "aaaa1111" assert payload["new_fingerprint"] == "bbbb2222" assert payload["table_count"] == 12 def test_rotation_rekey_failure_returns_503_and_leaves_keychain_unchanged( self, _fake_encrypted_env, monkeypatch ): from cyclone import db_crypto from cyclone import db from datetime import datetime, timezone def _fail_rotate(*, url, old_key, new_key): return db_crypto.RotateKeyResult( ok=False, old_fingerprint=db_crypto.fingerprint(old_key), new_fingerprint=db_crypto.fingerprint(new_key), rotated_at=datetime.now(timezone.utc).isoformat(), reason="simulated PRAGMA rekey failure", ) monkeypatch.setattr("cyclone.api._db_crypto.rotate_db_key", _fail_rotate) _, fake_kc = _fake_encrypted_env before = dict(fake_kc) from fastapi.testclient import TestClient from cyclone.api import app with TestClient(app) as client: r = client.post("/api/admin/db/rotate-key") assert r.status_code == 503 body = r.json()["detail"] assert body["ok"] is False assert "simulated" in body["reason"] # Keychain wasn't touched. assert fake_kc == before # No audit event was written. from cyclone.db import AuditLog with db.SessionLocal()() as session: count = ( session.query(AuditLog) .filter(AuditLog.event_type == "db.key_rotated") .count() ) assert count == 0 def test_503_when_keychain_write_fails_after_successful_rekey( self, _fake_encrypted_env, monkeypatch ): """The rekey itself succeeded but the Keychain write failed. The DB is now behind a new key the Keychain doesn't know about. Endpoint must return 503 so the operator can run the manual restore-key command.""" from cyclone import db # Override the set_secret at the import-site of the endpoint. monkeypatch.setattr("cyclone.api._secrets.set_secret", lambda n, v: False) from fastapi.testclient import TestClient from cyclone.api import app with TestClient(app) as client: r = client.post("/api/admin/db/rotate-key") assert r.status_code == 503 body = r.json()["detail"] assert body["ok"] is False assert "keychain" in body["reason"].lower() def test_409_when_concurrent_request(self, _fake_encrypted_env, monkeypatch): """A second concurrent rotation request gets 409 — only one rotation can run at a time (the module-level lock).""" monkeypatch.setattr( "cyclone.api._secrets.set_secret", lambda n, v: True, ) from cyclone import api as api_mod api_mod._db_rotate_lock.acquire() try: from fastapi.testclient import TestClient from cyclone.api import app with TestClient(app) as client: r = client.post("/api/admin/db/rotate-key") assert r.status_code == 409 assert "in progress" in r.json()["detail"] finally: api_mod._db_rotate_lock.release()