Persist desired and verified reconciliation state
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from dns_sync.adapters import CERTIFICATES, NormalizedObject
|
||||
from dns_sync.persistence import SQLiteStateStore
|
||||
|
||||
|
||||
ENDPOINT = "npm://192.168.1.3:81"
|
||||
T1 = datetime(2026, 9, 19, 21, 0, tzinfo=timezone.utc)
|
||||
T2 = datetime(2026, 9, 19, 21, 5, tzinfo=timezone.utc)
|
||||
T3 = datetime(2026, 9, 19, 21, 10, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def certificate(
|
||||
expires_on: str,
|
||||
local_key: str | None = None,
|
||||
) -> NormalizedObject:
|
||||
return NormalizedObject(
|
||||
object_type=CERTIFICATES,
|
||||
logical_key="app.example.com",
|
||||
local_key=local_key,
|
||||
payload={
|
||||
"domain_names": ["app.example.com"],
|
||||
"expires_on": expires_on,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class DesiredStatePersistenceTests(unittest.TestCase):
|
||||
def test_snapshot_round_trips_and_can_be_cleared(self):
|
||||
desired = certificate("2026-12-15T00:00:00+00:00")
|
||||
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.replace_desired_state(
|
||||
CERTIFICATES,
|
||||
[desired],
|
||||
T1,
|
||||
)
|
||||
loaded = store.load_desired_state(CERTIFICATES)
|
||||
|
||||
store.replace_desired_state(
|
||||
CERTIFICATES,
|
||||
[],
|
||||
T2,
|
||||
)
|
||||
cleared = store.load_desired_state(CERTIFICATES)
|
||||
|
||||
self.assertEqual(len(loaded), 1)
|
||||
self.assertEqual(loaded[0].object, desired)
|
||||
self.assertEqual(loaded[0].selected_at, T1)
|
||||
self.assertEqual(cleared, [])
|
||||
|
||||
def test_endpoint_local_identity_is_rejected(self):
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"endpoint-local key",
|
||||
):
|
||||
store.replace_desired_state(
|
||||
CERTIFICATES,
|
||||
[certificate("new", local_key="67")],
|
||||
T1,
|
||||
)
|
||||
|
||||
def test_sensitive_payload_is_rejected_before_snapshot_changes(self):
|
||||
safe = certificate("safe")
|
||||
unsafe = NormalizedObject(
|
||||
object_type=CERTIFICATES,
|
||||
logical_key="app.example.com",
|
||||
payload={
|
||||
"domain_names": ["app.example.com"],
|
||||
"nested": {
|
||||
"private_key": "must-never-persist",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.replace_desired_state(
|
||||
CERTIFICATES,
|
||||
[safe],
|
||||
T1,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"sensitive field",
|
||||
):
|
||||
store.replace_desired_state(
|
||||
CERTIFICATES,
|
||||
[unsafe],
|
||||
T2,
|
||||
)
|
||||
|
||||
loaded = store.load_desired_state(CERTIFICATES)
|
||||
|
||||
self.assertEqual(len(loaded), 1)
|
||||
self.assertEqual(loaded[0].object, safe)
|
||||
self.assertEqual(loaded[0].selected_at, T1)
|
||||
|
||||
|
||||
class AppliedStatePersistenceTests(unittest.TestCase):
|
||||
def test_apply_requires_successful_verification(self):
|
||||
first = certificate("first", local_key="91")
|
||||
second = certificate("second", local_key="91")
|
||||
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.record_applied(ENDPOINT, first, T1)
|
||||
before = store.load_applied_state(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)[0]
|
||||
|
||||
store.record_verification_success(
|
||||
ENDPOINT,
|
||||
first,
|
||||
T2,
|
||||
)
|
||||
verified = store.load_applied_state(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)[0]
|
||||
|
||||
store.record_applied(ENDPOINT, second, T3)
|
||||
reapplied = store.load_applied_state(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)[0]
|
||||
|
||||
self.assertFalse(before.is_verified)
|
||||
self.assertIsNone(before.verified_at)
|
||||
|
||||
self.assertTrue(verified.is_verified)
|
||||
self.assertEqual(verified.verified_at, T2)
|
||||
self.assertEqual(verified.verification_attempted_at, T2)
|
||||
|
||||
self.assertEqual(
|
||||
reapplied.object.payload["expires_on"],
|
||||
"second",
|
||||
)
|
||||
self.assertEqual(reapplied.applied_at, T3)
|
||||
self.assertFalse(reapplied.is_verified)
|
||||
self.assertIsNone(reapplied.verification_attempted_at)
|
||||
self.assertIsNone(reapplied.verified_at)
|
||||
|
||||
def test_stale_payload_cannot_verify_current_applied_state(self):
|
||||
old = certificate("old", local_key="91")
|
||||
current = certificate("current", local_key="91")
|
||||
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.record_applied(ENDPOINT, current, T1)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"does not match",
|
||||
):
|
||||
store.record_verification_success(
|
||||
ENDPOINT,
|
||||
old,
|
||||
T2,
|
||||
)
|
||||
|
||||
state = store.load_applied_state(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)[0]
|
||||
|
||||
self.assertFalse(state.is_verified)
|
||||
self.assertIsNone(state.verification_attempted_at)
|
||||
self.assertIsNone(state.verified_at)
|
||||
|
||||
def test_failed_verification_is_not_reported_as_verified(self):
|
||||
applied = certificate("current", local_key="91")
|
||||
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.record_applied(ENDPOINT, applied, T1)
|
||||
store.record_verification_success(
|
||||
ENDPOINT,
|
||||
applied,
|
||||
T2,
|
||||
)
|
||||
store.record_verification_failure(
|
||||
ENDPOINT,
|
||||
applied,
|
||||
"target no longer matches",
|
||||
T3,
|
||||
)
|
||||
state = store.load_applied_state(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)[0]
|
||||
|
||||
self.assertFalse(state.is_verified)
|
||||
self.assertEqual(state.verified_at, T2)
|
||||
self.assertEqual(state.verification_attempted_at, T3)
|
||||
self.assertEqual(
|
||||
state.verification_error,
|
||||
"target no longer matches",
|
||||
)
|
||||
|
||||
|
||||
class SchemaMigrationTests(unittest.TestCase):
|
||||
def test_version_one_database_is_migrated_to_version_two(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "state.sqlite3"
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(
|
||||
"CREATE TABLE schema_meta (version INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO schema_meta(version) VALUES (1)"
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
with SQLiteStateStore(path) as store:
|
||||
version = store.connection.execute(
|
||||
"SELECT version FROM schema_meta"
|
||||
).fetchone()[0]
|
||||
tables = {
|
||||
row[0]
|
||||
for row in store.connection.execute(
|
||||
"""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
self.assertEqual(version, 2)
|
||||
self.assertIn("desired_state", tables)
|
||||
self.assertIn("applied_state", tables)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user