Persist endpoint observations in SQLite
This commit is contained in:
@@ -0,0 +1,358 @@
|
|||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from os import PathLike
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .adapters import NormalizedObject, Observation
|
||||||
|
|
||||||
|
|
||||||
|
_FORBIDDEN_PERSISTED_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"private_key",
|
||||||
|
"certificate_key",
|
||||||
|
"certificate",
|
||||||
|
"dns_provider_credentials",
|
||||||
|
"secret",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ObservationStatus:
|
||||||
|
endpoint_id: str
|
||||||
|
object_type: str
|
||||||
|
last_attempt_at: datetime
|
||||||
|
last_success_at: datetime | None
|
||||||
|
last_error: str | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_live(self) -> bool:
|
||||||
|
return self.last_error is None
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value: datetime, label: str) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
raise RuntimeError(f"{label} must be timezone-aware")
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp(value: datetime, label: str) -> str:
|
||||||
|
return _utc(value, label).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Persisted timestamp is not timezone-aware: {value!r}"
|
||||||
|
)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_safe_payload(value: Any, path: str = "payload") -> None:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for raw_key, child in value.items():
|
||||||
|
key = str(raw_key).strip().lower().replace("-", "_")
|
||||||
|
if key in _FORBIDDEN_PERSISTED_KEYS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Refusing to persist sensitive field {path}.{key}"
|
||||||
|
)
|
||||||
|
_validate_safe_payload(child, f"{path}.{key}")
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
for index, child in enumerate(value):
|
||||||
|
_validate_safe_payload(child, f"{path}[{index}]")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_payload(payload: Mapping[str, Any]) -> str:
|
||||||
|
_validate_safe_payload(payload)
|
||||||
|
try:
|
||||||
|
return json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Object payload is not JSON serializable: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_payload(value: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(value)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError("Persisted object payload is invalid JSON") from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RuntimeError("Persisted object payload is not a mapping")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteStateStore:
|
||||||
|
def __init__(self, path: str | PathLike[str]):
|
||||||
|
self.path = str(path)
|
||||||
|
self.connection = sqlite3.connect(self.path)
|
||||||
|
self.connection.row_factory = sqlite3.Row
|
||||||
|
self.connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
self.connection.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
if self.path != ":memory:":
|
||||||
|
self.connection.execute("PRAGMA journal_mode = WAL")
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self.connection:
|
||||||
|
self.connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||||
|
version INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO schema_meta(version)
|
||||||
|
SELECT 1
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM schema_meta
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS observations (
|
||||||
|
endpoint_id TEXT NOT NULL,
|
||||||
|
object_type TEXT NOT NULL,
|
||||||
|
logical_key TEXT NOT NULL,
|
||||||
|
local_key TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
observed_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
local_key
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS
|
||||||
|
observations_by_logical_identity
|
||||||
|
ON observations (
|
||||||
|
object_type,
|
||||||
|
logical_key
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS observation_status (
|
||||||
|
endpoint_id TEXT NOT NULL,
|
||||||
|
object_type TEXT NOT NULL,
|
||||||
|
last_attempt_at TEXT NOT NULL,
|
||||||
|
last_success_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
PRIMARY KEY (
|
||||||
|
endpoint_id,
|
||||||
|
object_type
|
||||||
|
)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.connection.close()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, traceback):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def replace_observations(
|
||||||
|
self,
|
||||||
|
endpoint_id: str,
|
||||||
|
object_type: str,
|
||||||
|
observations: Iterable[Observation],
|
||||||
|
snapshot_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
rows = list(observations)
|
||||||
|
snapshot_timestamp = _timestamp(
|
||||||
|
snapshot_at,
|
||||||
|
"Observation snapshot timestamp",
|
||||||
|
)
|
||||||
|
prepared: list[tuple[str, str, str, str, str, str]] = []
|
||||||
|
local_keys: set[str] = set()
|
||||||
|
|
||||||
|
for observation in rows:
|
||||||
|
normalized = observation.object
|
||||||
|
if observation.endpoint_id != endpoint_id:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Observation endpoint does not match snapshot endpoint"
|
||||||
|
)
|
||||||
|
if normalized.object_type != object_type:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Observation object type does not match snapshot type"
|
||||||
|
)
|
||||||
|
if normalized.local_key is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Observed objects require an endpoint-local key"
|
||||||
|
)
|
||||||
|
if normalized.local_key in local_keys:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Duplicate endpoint-local key: {normalized.local_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
local_keys.add(normalized.local_key)
|
||||||
|
prepared.append(
|
||||||
|
(
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
normalized.logical_key,
|
||||||
|
normalized.local_key,
|
||||||
|
_serialize_payload(normalized.payload),
|
||||||
|
_timestamp(
|
||||||
|
observation.observed_at,
|
||||||
|
"Observation timestamp",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.connection:
|
||||||
|
self.connection.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM observations
|
||||||
|
WHERE endpoint_id = ? AND object_type = ?
|
||||||
|
""",
|
||||||
|
(endpoint_id, object_type),
|
||||||
|
)
|
||||||
|
self.connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO observations (
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
logical_key,
|
||||||
|
local_key,
|
||||||
|
payload_json,
|
||||||
|
observed_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
prepared,
|
||||||
|
)
|
||||||
|
self.connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO observation_status (
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
last_attempt_at,
|
||||||
|
last_success_at,
|
||||||
|
last_error
|
||||||
|
) VALUES (?, ?, ?, ?, NULL)
|
||||||
|
ON CONFLICT(endpoint_id, object_type) DO UPDATE SET
|
||||||
|
last_attempt_at = excluded.last_attempt_at,
|
||||||
|
last_success_at = excluded.last_success_at,
|
||||||
|
last_error = NULL
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
snapshot_timestamp,
|
||||||
|
snapshot_timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_discovery_failure(
|
||||||
|
self,
|
||||||
|
endpoint_id: str,
|
||||||
|
object_type: str,
|
||||||
|
error: str,
|
||||||
|
attempted_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
attempted_timestamp = _timestamp(
|
||||||
|
attempted_at,
|
||||||
|
"Discovery attempt timestamp",
|
||||||
|
)
|
||||||
|
with self.connection:
|
||||||
|
self.connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO observation_status (
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
last_attempt_at,
|
||||||
|
last_success_at,
|
||||||
|
last_error
|
||||||
|
) VALUES (?, ?, ?, NULL, ?)
|
||||||
|
ON CONFLICT(endpoint_id, object_type) DO UPDATE SET
|
||||||
|
last_attempt_at = excluded.last_attempt_at,
|
||||||
|
last_error = excluded.last_error
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
attempted_timestamp,
|
||||||
|
str(error),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_observations(
|
||||||
|
self,
|
||||||
|
object_type: str | None = None,
|
||||||
|
) -> list[Observation]:
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
logical_key,
|
||||||
|
local_key,
|
||||||
|
payload_json,
|
||||||
|
observed_at
|
||||||
|
FROM observations
|
||||||
|
"""
|
||||||
|
parameters: tuple[str, ...] = ()
|
||||||
|
if object_type is not None:
|
||||||
|
sql += " WHERE object_type = ?"
|
||||||
|
parameters = (object_type,)
|
||||||
|
sql += """
|
||||||
|
ORDER BY
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
logical_key,
|
||||||
|
local_key
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = self.connection.execute(sql, parameters).fetchall()
|
||||||
|
return [
|
||||||
|
Observation(
|
||||||
|
endpoint_id=row["endpoint_id"],
|
||||||
|
object=NormalizedObject(
|
||||||
|
object_type=row["object_type"],
|
||||||
|
logical_key=row["logical_key"],
|
||||||
|
local_key=row["local_key"],
|
||||||
|
payload=_deserialize_payload(row["payload_json"]),
|
||||||
|
),
|
||||||
|
observed_at=_parse_timestamp(row["observed_at"]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def observation_status(
|
||||||
|
self,
|
||||||
|
endpoint_id: str,
|
||||||
|
object_type: str,
|
||||||
|
) -> ObservationStatus | None:
|
||||||
|
row = self.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
endpoint_id,
|
||||||
|
object_type,
|
||||||
|
last_attempt_at,
|
||||||
|
last_success_at,
|
||||||
|
last_error
|
||||||
|
FROM observation_status
|
||||||
|
WHERE endpoint_id = ? AND object_type = ?
|
||||||
|
""",
|
||||||
|
(endpoint_id, object_type),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ObservationStatus(
|
||||||
|
endpoint_id=row["endpoint_id"],
|
||||||
|
object_type=row["object_type"],
|
||||||
|
last_attempt_at=_parse_timestamp(row["last_attempt_at"]),
|
||||||
|
last_success_at=_parse_timestamp(row["last_success_at"]),
|
||||||
|
last_error=row["last_error"],
|
||||||
|
)
|
||||||
@@ -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