forked from ChelseaKR/constituent-reconciler
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_no_egress.py
More file actions
270 lines (216 loc) · 10.9 KB
/
Copy pathtest_no_egress.py
File metadata and controls
270 lines (216 loc) · 10.9 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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""Merge-blocking DV-pack invariants: client PII must not egress.
These tests encode the confidentiality posture a victim-service provider needs
under VAWA and FVPSA, as enforced behavior. If any of them fails, the pack is no
longer safe to claim, so they gate the merge. The legal grounding for each
invariant is recorded in docs/RESPONSIBLE-TECH-AUDITS.md.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from constituent_reconciler import pipeline, stage_cache
from constituent_reconciler.config import RecipeError, load_recipe
from constituent_reconciler.consent import partition_by_consent
from constituent_reconciler.extract.seam import LocalSeam, NoOpSeam, make_seam
from constituent_reconciler.policy import PolicyViolation
EXAMPLES = Path(__file__).resolve().parents[1] / "examples" / "intake-demo"
_DV_CACHE_RECIPE = """
[input]
existing = "{examples}/existing.csv"
incoming = "{examples}/incoming.csv"
id_column = "id"
[mapping]
first_name = "First Name"
last_name = "Last Name"
dob = "DOB"
email = "Email"
phone = "Phone"
[consent]
column = "Consent"
[policy]
pack = "dv"
[cache]
enabled = true
{cache_dir_line}
"""
def _write_dv_cache_recipe(tmp_path: Path, *, cache_dir: str | None = None) -> Path:
path = tmp_path / "recipe.toml"
line = f'dir = "{cache_dir}"' if cache_dir is not None else ""
path.write_text(
_DV_CACHE_RECIPE.format(examples=EXAMPLES, cache_dir_line=line), encoding="utf-8"
)
return path
def test_dv_pack_refuses_a_non_local_write_target(tmp_path: Path) -> None:
# CiviCRM is a network target; under the DV pack the export must refuse it
# rather than send client records off the machine.
recipe = load_recipe(EXAMPLES / "recipe-civicrm.toml", policy_pack="dv")
result = pipeline.run(recipe)
with pytest.raises(PolicyViolation, match="non-local write target"):
pipeline.export(result, recipe, out_dir=tmp_path)
# Nothing was written: the refusal happens before any connector write.
assert not (tmp_path / "resolved.csv").exists()
def test_dv_pack_refuses_the_salesforce_target_too(tmp_path: Path) -> None:
from dataclasses import replace
from constituent_reconciler.config import OutputConfig
base = load_recipe(EXAMPLES / "recipe.toml", policy_pack="dv")
recipe = replace(
base,
output=OutputConfig(connector="salesforce", endpoint="https://x.my.salesforce.com"),
)
result = pipeline.run(recipe)
with pytest.raises(PolicyViolation, match="non-local write target"):
pipeline.export(result, recipe, out_dir=tmp_path)
assert not (tmp_path / "resolved.csv").exists()
def test_dv_pack_refuses_the_webhook_target_too(tmp_path: Path) -> None:
from dataclasses import replace
from constituent_reconciler.config import OutputConfig
base = load_recipe(EXAMPLES / "recipe.toml", policy_pack="dv")
recipe = replace(
base,
output=OutputConfig(connector="webhook", endpoint="https://example.org/hooks/reconciler"),
)
result = pipeline.run(recipe)
with pytest.raises(PolicyViolation, match="non-local write target"):
pipeline.export(result, recipe, out_dir=tmp_path)
# Nothing was written: the refusal happens before any connector write, so no
# network POST was even attempted, let alone one carrying client PII.
assert not (tmp_path / "resolved.csv").exists()
def test_dv_pack_refuses_the_airtable_target_too(tmp_path: Path) -> None:
from dataclasses import replace
from constituent_reconciler.config import OutputConfig
base = load_recipe(EXAMPLES / "recipe.toml", policy_pack="dv")
recipe = replace(
base,
output=OutputConfig(
connector="airtable",
endpoint="https://api.airtable.com/v0/app123/Constituents",
),
)
result = pipeline.run(recipe)
with pytest.raises(PolicyViolation, match="non-local write target"):
pipeline.export(result, recipe, out_dir=tmp_path)
assert not (tmp_path / "resolved.csv").exists()
def test_dv_pack_allows_the_local_csv_target(tmp_path: Path) -> None:
recipe = load_recipe(EXAMPLES / "recipe-dv.toml")
result = pipeline.run(recipe)
summary = pipeline.export(result, recipe, out_dir=tmp_path)
# The local CSV is permitted; the org keeps client data in its own database.
assert (tmp_path / "resolved.csv").exists()
assert summary.aggregate is not None
def test_dv_pack_refuses_a_network_timestamp_authority(tmp_path: Path) -> None:
from dataclasses import replace
# The RFC 3161 request sends a hash derived from written client fields to
# the TSA. VAWA/FVPSA guidance treats hashed client information as still
# protected, so the DV pack refuses the network authority before any write.
base = load_recipe(EXAMPLES / "recipe-dv.toml")
recipe = replace(base, tsa_url="https://tsa.example/tsr")
result = pipeline.run(recipe)
with pytest.raises(PolicyViolation, match="timestamp"):
pipeline.export(result, recipe, out_dir=tmp_path)
assert not (tmp_path / "resolved.csv").exists()
assert not (tmp_path / "provenance.jsonl").exists()
def test_dv_pack_fuses_the_cloud_extraction_seam_off() -> None:
# Even if a recipe asks for the Bedrock backend, the DV pack returns a NoOp
# seam: no page image, no field value, leaves the machine.
seam = make_seam("dv", backend="bedrock")
assert isinstance(seam, NoOpSeam)
assert seam.is_enabled() is False
def test_dv_pack_fuses_the_local_seam_off_by_default() -> None:
# A local model does not egress PII, but that is a separate question from
# whether model-assisted extraction is acceptable at all under a given
# org's VAWA reading (docs/adr/0010-local-model-seam.md). The dv
# pack has not recorded that analysis, so backend="local" alone still
# produces a NoOp seam, same as backend="bedrock".
seam = make_seam("dv", backend="local")
assert isinstance(seam, NoOpSeam)
assert seam.is_enabled() is False
def test_dv_pack_local_seam_still_cannot_egress_even_when_explicitly_enabled() -> None:
# A deployer whose counsel has cleared model-assisted extraction can opt
# in via local_model_override. Even then, the resulting seam is forced
# to loopback: this proves that turning on model-assisted extraction
# under dv never opens a path to a non-local host, regardless of any
# OLLAMA_HOST a deployer's environment might set to something else.
seam = make_seam("dv", backend="local", local_model_override=True)
assert isinstance(seam, LocalSeam)
with pytest.raises(ValueError, match="loopback"):
LocalSeam(host="http://not-loopback.example.com:11434")
def test_dv_pack_withholds_non_consented_records_without_field_values(tmp_path: Path) -> None:
recipe = load_recipe(EXAMPLES / "recipe-dv.toml")
result = pipeline.run(recipe)
exportable, withheld = partition_by_consent(
result.golden, require_consent=recipe.require_consent
)
# N009 (consent revoked) is withheld.
withheld_members = {m for entry in withheld for m in entry.members}
assert "incoming:N009" in withheld_members
summary = pipeline.export(result, recipe, out_dir=tmp_path)
assert summary.withheld_path is not None
withheld_text = summary.withheld_path.read_text(encoding="utf-8")
# The withheld record is recorded by id and reason only; no field value of a
# non-consented person appears in the artifact. The reason distinguishes an
# explicit revocation from a merely absent consent.
assert "incoming:N009" in withheld_text or any(
"incoming:N009" in w.members for w in summary.withheld
)
assert "revoked" in withheld_text
def test_dv_aggregate_summary_carries_no_field_values(tmp_path: Path) -> None:
recipe = load_recipe(EXAMPLES / "recipe-dv.toml")
result = pipeline.run(recipe)
summary = pipeline.export(result, recipe, out_dir=tmp_path)
assert summary.aggregate_path is not None
payload = json.loads(summary.aggregate_path.read_text(encoding="utf-8"))
assert "total_resolved" in payload
assert "breakdowns" in payload
# No resolved record's name, email, or member id may appear in the shareable
# aggregate. Check against the actual exportable field values.
exportable, _ = partition_by_consent(result.golden, require_consent=recipe.require_consent)
blob = summary.aggregate_path.read_text(encoding="utf-8")
for record in exportable:
for value in record.fields.values():
if value:
assert value not in blob
assert record.cluster_id not in blob
def test_default_pack_writes_no_aggregate_summary(tmp_path: Path) -> None:
# The aggregate artifact is a DV-pack obligation, not a default behavior.
recipe = load_recipe(EXAMPLES / "recipe.toml")
result = pipeline.run(recipe)
summary = pipeline.export(result, recipe, out_dir=tmp_path)
assert summary.aggregate is None
assert summary.aggregate_path is None
assert not (tmp_path / "aggregate_summary.json").exists()
def test_stage_cache_writes_only_under_its_configured_local_directory(tmp_path: Path) -> None:
# The stage cache stores extracted and normalized field values, so where
# it writes is a data-boundary question. With an explicitly configured
# local boundary, every file the cached run creates lands under that
# boundary and nowhere else, including under the out root.
boundary = tmp_path / "boundary"
out_dir = tmp_path / "out"
recipe = load_recipe(_write_dv_cache_recipe(tmp_path, cache_dir=str(boundary)))
cache = stage_cache.for_recipe(recipe, out_dir)
assert cache is not None
assert cache.root == boundary
before = {p for p in tmp_path.rglob("*") if p.is_file()}
pipeline.run(recipe, cache=cache)
created = {p for p in tmp_path.rglob("*") if p.is_file()} - before
assert created, "the cached run must have written cache entries"
assert all(p.is_relative_to(boundary) for p in created)
assert not out_dir.exists()
def test_dv_pack_cache_defaults_under_the_local_output_root(tmp_path: Path) -> None:
# Absent an explicit boundary, the DV pack's cache lives inside the run's
# own output root: the one directory the operator already controls and
# `reconcile destroy` already reaches.
out_dir = tmp_path / "out"
recipe = load_recipe(_write_dv_cache_recipe(tmp_path))
cache = stage_cache.for_recipe(recipe, out_dir)
assert cache is not None
assert cache.root == out_dir / "stage_cache"
pipeline.run(recipe, cache=cache)
entries = [p for p in (out_dir / "stage_cache").rglob("*") if p.is_file()]
assert entries
def test_cache_dir_refuses_a_non_local_value(tmp_path: Path) -> None:
# A URL-shaped cache directory would point PII at a remote boundary; the
# recipe loader refuses it before any record is read.
for value in ("s3://bucket/cache", "https://cache.example.org/x"):
path = _write_dv_cache_recipe(tmp_path, cache_dir=value)
with pytest.raises(RecipeError, match="local filesystem path"):
load_recipe(path)