forked from ChelseaKR/id-churn-sentinel
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_baseline.py
More file actions
172 lines (129 loc) · 6.63 KB
/
Copy pathtest_baseline.py
File metadata and controls
172 lines (129 loc) · 6.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
"""Tests for :mod:`id_churn_sentinel.core.baseline` — the committed baseline hashes.
Two things are under test. The mechanics (round-trip, validation, drift detection), and one
discipline that matters more than the mechanics: **a source we could not fetch gets no
hash.** A baseline entry is a record of something we observed. Inventing one for a page that
403'd us would be a fabricated observation, and every downstream comparison would inherit it.
The committed `sources/baseline-hashes.json` is also checked for consistency against the
committed registry — offline, with no network, like everything else in this suite.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from id_churn_sentinel.core.baseline import (
BASELINE_VERSION,
check_baselines,
default_baseline_path,
load_baselines,
write_baselines,
)
from id_churn_sentinel.core.detect import _watch_authorized_sources as watch
from id_churn_sentinel.core.registry import Registry, Source, load_registry
from id_churn_sentinel.core.store import SnapshotStore
from id_churn_sentinel.errors import RegistryError
from .conftest import StubFetcher
GENERATED = datetime(2026, 7, 13, 12, 0, tzinfo=UTC)
@pytest.fixture
def registry(source: Source) -> Registry:
"""A one-source registry, shadowing the shared fixture.
The shared `registry` carries three sources because `publish()` needs one per jurisdiction
it emits a feed for. These tests are about *one* source's baseline hash, and the assertions
below ("this source, and nothing else, is unreachable") are sharper with one.
"""
return Registry(version="1.0", sources=(source,))
def test_write_then_load_round_trips_the_hash(
tmp_path: Path, registry: Registry, source: Source, store: SnapshotStore, fetcher: StubFetcher
) -> None:
watch([source], store, fetcher)
out = tmp_path / "baseline-hashes.json"
written = write_baselines(store, registry, out, now=GENERATED)
assert written == 1
loaded = load_baselines(out)
assert loaded[source.id] == store.latest_snapshot(source.id).content_sha256 # type: ignore[union-attr]
def test_a_source_we_could_not_fetch_gets_no_hash(
tmp_path: Path, registry: Registry, source: Source, store: SnapshotStore
) -> None:
"""`ssa.gov` 403s us and always has. It is named as unreachable and carries NO baseline
— a hash we did not observe is not a hash, and a fabricated one would be laundered into
fact by every comparison downstream."""
watch([source], store, StubFetcher()) # every fetch fails
out = tmp_path / "baseline-hashes.json"
written = write_baselines(store, registry, out, now=GENERATED)
assert written == 0
payload = json.loads(out.read_text())
assert payload["unreachable"] == [source.id]
assert payload["baselines"] == {}
assert load_baselines(out) == {}
def test_check_baselines_detects_a_moved_page(
source: Source, fixture_before: bytes, fixture_after: bytes
) -> None:
before = StubFetcher({source.url: (fixture_before, "text/html")})
committed = {source.id: _hash_of(before, source)}
report = check_baselines(
[source], StubFetcher({source.url: (fixture_after, "text/html")}), committed
)
assert report.matched == []
assert len(report.moved) == 1
moved_id, was, now = report.moved[0]
assert moved_id == source.id
assert was != now
assert "MOVED" in report.summary()
def test_check_baselines_is_quiet_when_the_page_has_not_moved(
source: Source, fixture_before: bytes, fixture_cosmetic: bytes
) -> None:
"""And cosmetic markup churn is not a move — the baseline is over the NORMALIZED text,
so a re-minified stylesheet does not wake anyone up here either."""
unchanged = StubFetcher({source.url: (fixture_before, "text/html")})
committed = {source.id: _hash_of(unchanged, source)}
report = check_baselines(
[source], StubFetcher({source.url: (fixture_cosmetic, "text/html")}), committed
)
assert report.matched == [source.id]
assert report.moved == []
def test_an_unreachable_source_is_never_reported_as_moved(source: Source) -> None:
"""The rule that governs the whole tool, restated here because this is a second code
path that could have broken it: a fetch failure is never drift."""
report = check_baselines([source], StubFetcher(), {source.id: "a" * 64})
assert report.moved == []
assert report.matched == []
assert report.unreachable == [(source.id, "stubbed outage: no response configured")]
assert "not drift" in report.summary()
def test_a_source_with_no_committed_baseline_is_named_not_guessed(
source: Source, fixture_before: bytes
) -> None:
report = check_baselines([source], StubFetcher({source.url: (fixture_before, "text/html")}), {})
assert report.unbaselined == [source.id]
assert report.moved == []
def test_a_malformed_baseline_file_is_loud(tmp_path: Path) -> None:
"""A silently-wrong baseline would compare a live page against nonsense and report drift
that never happened. That is worse than having no baseline at all."""
bad = tmp_path / "b.json"
bad.write_text(json.dumps({"baseline_version": "0.9", "baselines": {}}))
with pytest.raises(RegistryError, match="baseline_version"):
load_baselines(bad)
missing_hash = tmp_path / "c.json"
missing_hash.write_text(
json.dumps({"baseline_version": BASELINE_VERSION, "baselines": {"x": {"url": "u"}}})
)
with pytest.raises(RegistryError, match="no sha256"):
load_baselines(missing_hash)
with pytest.raises(RegistryError, match="not found"):
load_baselines(tmp_path / "nope.json")
def test_the_committed_baseline_matches_the_committed_registry() -> None:
"""Offline, and merge-relevant: every id in the committed baseline must be a real source
in the committed registry. A baseline for a source that no longer exists is a stale
claim, and a stale claim in this repo is the failure mode, not a tidiness problem."""
registry = load_registry()
baselines = load_baselines(default_baseline_path())
known = {source.id for source in registry.sources}
assert baselines, (
"the committed baseline is empty — run `sentinel watch && sentinel baseline write`"
)
orphans = set(baselines) - known
assert not orphans, f"baseline hashes for sources not in the registry: {sorted(orphans)}"
assert all(len(h) == 64 for h in baselines.values())
def _hash_of(fetcher: StubFetcher, source: Source) -> str:
from id_churn_sentinel.core.normalize import content_hash
result = fetcher.fetch(source.url)
return content_hash(result.body, result.content_type)[0]