Persist endpoint observations in SQLite
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from dns_sync.adapters import (
|
||||
CERTIFICATES,
|
||||
NormalizedObject,
|
||||
Observation,
|
||||
)
|
||||
from dns_sync.persistence import SQLiteStateStore
|
||||
|
||||
|
||||
ENDPOINT = "npm://192.168.1.3:81"
|
||||
T1 = datetime(2026, 9, 19, 20, 0, tzinfo=timezone.utc)
|
||||
T2 = datetime(2026, 9, 19, 20, 5, tzinfo=timezone.utc)
|
||||
T3 = datetime(2026, 9, 19, 20, 10, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def certificate(
|
||||
local_key: str,
|
||||
expires_on: str,
|
||||
observed_at: datetime = T1,
|
||||
payload=None,
|
||||
) -> Observation:
|
||||
return Observation(
|
||||
endpoint_id=ENDPOINT,
|
||||
object=NormalizedObject(
|
||||
object_type=CERTIFICATES,
|
||||
logical_key="app.example.com",
|
||||
local_key=local_key,
|
||||
payload=payload
|
||||
or {
|
||||
"domain_names": ["app.example.com"],
|
||||
"expires_on": expires_on,
|
||||
},
|
||||
),
|
||||
observed_at=observed_at,
|
||||
)
|
||||
|
||||
|
||||
class ObservationPersistenceTests(unittest.TestCase):
|
||||
def test_snapshot_preserves_duplicate_logical_identity(self):
|
||||
rows = [
|
||||
certificate("63", "2026-12-01T00:00:00+00:00"),
|
||||
certificate("67", "2026-12-15T00:00:00+00:00"),
|
||||
]
|
||||
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
rows,
|
||||
T1,
|
||||
)
|
||||
loaded = store.load_observations(CERTIFICATES)
|
||||
|
||||
self.assertEqual(
|
||||
tuple(item.object.local_key for item in loaded),
|
||||
("63", "67"),
|
||||
)
|
||||
self.assertEqual(
|
||||
{item.object.logical_key for item in loaded},
|
||||
{"app.example.com"},
|
||||
)
|
||||
|
||||
def test_live_snapshot_atomically_replaces_previous_rows(self):
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[
|
||||
certificate("63", "old"),
|
||||
certificate("67", "newer"),
|
||||
],
|
||||
T1,
|
||||
)
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[
|
||||
certificate(
|
||||
"67",
|
||||
"newest",
|
||||
observed_at=T2,
|
||||
)
|
||||
],
|
||||
T2,
|
||||
)
|
||||
loaded = store.load_observations(CERTIFICATES)
|
||||
status = store.observation_status(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)
|
||||
|
||||
self.assertEqual(len(loaded), 1)
|
||||
self.assertEqual(loaded[0].object.local_key, "67")
|
||||
self.assertEqual(
|
||||
loaded[0].object.payload["expires_on"],
|
||||
"newest",
|
||||
)
|
||||
self.assertTrue(status.is_live)
|
||||
self.assertEqual(status.last_success_at, T2)
|
||||
self.assertIsNone(status.last_error)
|
||||
|
||||
def test_empty_live_snapshot_removes_previous_rows(self):
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[certificate("63", "old")],
|
||||
T1,
|
||||
)
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[],
|
||||
T2,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
store.load_observations(CERTIFICATES),
|
||||
[],
|
||||
)
|
||||
self.assertTrue(
|
||||
store.observation_status(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
).is_live
|
||||
)
|
||||
|
||||
def test_failed_discovery_preserves_cache_until_live_recovery(self):
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[certificate("63", "cached")],
|
||||
T1,
|
||||
)
|
||||
store.record_discovery_failure(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
"connection refused",
|
||||
T2,
|
||||
)
|
||||
|
||||
cached = store.load_observations(CERTIFICATES)
|
||||
failed = store.observation_status(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)
|
||||
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[
|
||||
certificate(
|
||||
"67",
|
||||
"live",
|
||||
observed_at=T3,
|
||||
)
|
||||
],
|
||||
T3,
|
||||
)
|
||||
recovered = store.observation_status(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)
|
||||
|
||||
self.assertEqual(len(cached), 1)
|
||||
self.assertEqual(cached[0].object.local_key, "63")
|
||||
self.assertFalse(failed.is_live)
|
||||
self.assertEqual(failed.last_success_at, T1)
|
||||
self.assertEqual(failed.last_attempt_at, T2)
|
||||
self.assertEqual(failed.last_error, "connection refused")
|
||||
self.assertTrue(recovered.is_live)
|
||||
self.assertEqual(recovered.last_success_at, T3)
|
||||
|
||||
def test_sensitive_payload_is_rejected_before_cache_changes(self):
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[certificate("63", "safe")],
|
||||
T1,
|
||||
)
|
||||
unsafe = certificate(
|
||||
"67",
|
||||
"unsafe",
|
||||
observed_at=T2,
|
||||
payload={
|
||||
"domain_names": ["app.example.com"],
|
||||
"nested": {
|
||||
"private_key": "must-never-persist",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"sensitive field",
|
||||
):
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[unsafe],
|
||||
T2,
|
||||
)
|
||||
|
||||
cached = store.load_observations(CERTIFICATES)
|
||||
|
||||
self.assertEqual(len(cached), 1)
|
||||
self.assertEqual(cached[0].object.local_key, "63")
|
||||
|
||||
def test_duplicate_local_keys_are_rejected(self):
|
||||
duplicate = certificate("63", "duplicate")
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"Duplicate endpoint-local key",
|
||||
):
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[duplicate, duplicate],
|
||||
T1,
|
||||
)
|
||||
|
||||
def test_file_database_survives_reopen(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "state.sqlite3"
|
||||
|
||||
with SQLiteStateStore(path) as store:
|
||||
store.replace_observations(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
[certificate("63", "durable")],
|
||||
T1,
|
||||
)
|
||||
|
||||
with SQLiteStateStore(path) as reopened:
|
||||
loaded = reopened.load_observations(CERTIFICATES)
|
||||
status = reopened.observation_status(
|
||||
ENDPOINT,
|
||||
CERTIFICATES,
|
||||
)
|
||||
|
||||
self.assertEqual(len(loaded), 1)
|
||||
self.assertEqual(
|
||||
loaded[0].object.payload["expires_on"],
|
||||
"durable",
|
||||
)
|
||||
self.assertEqual(status.last_success_at, T1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user