258 lines
7.6 KiB
Python
258 lines
7.6 KiB
Python
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()
|