forked from ChelseaKR/fare-policy-assistant
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_web.py
More file actions
522 lines (433 loc) · 21.8 KB
/
Copy pathtest_web.py
File metadata and controls
522 lines (433 loc) · 21.8 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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
"""The Lambda handler: routing, validation, and the privacy/budget guards.
These tests run the real handler against the committed corpus with the mock
model (FPA_PROVIDER=mock), so they exercise the full pipeline offline.
"""
from __future__ import annotations
import json
import pytest
from assistant.answer import AnswerResult, Citation
from web import handler as web_handler
@pytest.fixture(autouse=True)
def offline(monkeypatch):
monkeypatch.setenv("FPA_PROVIDER", "mock")
web_handler._RECENT.clear()
web_handler._ANSWER_CACHE.clear()
def _event(method: str = "POST", path: str = "/api/ask", body: dict | None = None) -> dict:
return {
"requestContext": {"http": {"method": method}},
"rawPath": path,
"body": json.dumps(body) if body is not None else None,
}
def _post(question: str) -> dict:
return web_handler.handler(_event(body={"question": question}))
class TestRouting:
def test_index_served_at_root(self):
resp = web_handler.handler(_event(method="GET", path="/"))
assert resp["statusCode"] == 200
assert "text/html" in resp["headers"]["content-type"]
assert "Reference implementation" in resp["body"]
def test_unknown_path_404(self):
resp = web_handler.handler(_event(method="GET", path="/admin"))
assert resp["statusCode"] == 404
def test_get_on_api_405(self):
resp = web_handler.handler(_event(method="GET", path="/api/ask"))
assert resp["statusCode"] == 405
def test_security_headers_present(self):
resp = web_handler.handler(_event(method="GET", path="/"))
assert resp["headers"]["x-frame-options"] == "DENY"
assert "content-security-policy" in resp["headers"]
def test_no_route_allows_unsafe_inline(self):
# The CSP hashes inline blocks instead of blanket-allowing them; no
# response may fall back to 'unsafe-inline' (FIX-10).
for path in (
"/",
"/offline",
"/guide",
"/embed",
"/version",
"/api/ask",
"/api/feedback",
):
resp = web_handler.handler(_event(method="GET", path=path))
csp = resp["headers"].get("content-security-policy", "")
assert "unsafe-inline" not in csp, f"{path} CSP allows unsafe-inline: {csp}"
@pytest.mark.parametrize("path", ["/", "/offline", "/guide", "/embed"])
def test_inline_block_hashes_appear_in_csp(self, path):
# Drift guard: recompute the sha256 of every inline <script>/<style>
# block from the *served* body and assert each token is in the CSP. If
# markup and policy ever drift apart, the browser would refuse the block
# and this fails first.
from web.csp import script_hashes, style_hashes
resp = web_handler.handler(_event(method="GET", path=path))
body = resp["body"]
csp = resp["headers"]["content-security-policy"]
tokens = script_hashes(body) + style_hashes(body)
assert tokens, f"{path} served no inline blocks to hash"
for token in tokens:
assert token in csp, f"{path} CSP is missing {token}"
def test_live_region_present_for_answer_status(self):
# New answers and status are announced through a polite live region
# (persona research F-8); lock it so a refactor cannot drop it.
body = web_handler.handler(_event(method="GET", path="/"))["body"]
assert 'role="status"' in body
assert 'aria-live="polite"' in body
def test_display_settings_controls_present(self):
# Text-size and high-contrast controls for low-vision and older riders
# (persona research F-2). They are labeled and toggle via aria-pressed.
body = web_handler.handler(_event(method="GET", path="/"))["body"]
assert 'aria-label="Display settings"' in body
for control_id in ("tsize-normal", "tsize-large", "tsize-xlarge", "contrast"):
assert f'id="{control_id}"' in body
assert "aria-pressed" in body
class TestOfflineReference:
def test_offline_page_served(self):
resp = web_handler.handler(_event(method="GET", path="/offline"))
assert resp["statusCode"] == 200
assert "text/html" in resp["headers"]["content-type"]
body = resp["body"]
# Built from the committed corpus: every agency and the as-of framing.
for agency_full in ("Monterey-Salinas Transit", "Humboldt Transit Authority"):
assert agency_full in body
assert "published as of" in body
assert "Reference implementation" in body
# Citable sources are resolvable links, not internal doc ids.
assert "https://" in body and "[doc:" not in body
def test_offline_page_passes_structural_a11y(self):
from web.a11y import check_html
body = web_handler.handler(_event(method="GET", path="/offline"))["body"]
assert check_html(body) == []
class TestGuidedFareFinder:
def test_guide_page_served(self):
resp = web_handler.handler(_event(method="GET", path="/guide"))
assert resp["statusCode"] == 200
assert "text/html" in resp["headers"]["content-type"]
body = resp["body"]
for agency_full in ("Monterey-Salinas Transit", "Humboldt Transit Authority"):
assert agency_full in body
assert "published as of" in body
assert "Reference implementation" in body
assert "https://" in body and "[doc:" not in body
def test_guide_page_passes_structural_a11y(self):
from web.a11y import check_html
body = web_handler.handler(_event(method="GET", path="/guide"))["body"]
assert check_html(body) == []
def test_guide_page_has_no_input_fields(self):
# EXP-07's excellence bar: zero input fields, even though a form-like
# walkthrough invites collecting rider attributes.
body = web_handler.handler(_event(method="GET", path="/guide"))["body"]
for tag in ("<input", "<textarea", "<select"):
assert tag not in body
def test_guide_page_never_claims_to_decide_eligibility(self):
body = web_handler.handler(_event(method="GET", path="/guide"))["body"]
assert "does not decide whether you qualify" in body
def test_guide_page_reachable_from_index(self):
body = web_handler.handler(_event(method="GET", path="/"))["body"]
assert 'href="/guide"' in body
class TestVersion:
def _version(self):
return web_handler.handler(_event(method="GET", path="/version"))
def test_version_reports_corpus_identity(self):
resp = self._version()
assert resp["statusCode"] == 200
data = json.loads(resp["body"])
assert len(data["corpus_version"]) == 12
assert data["as_of"]
assert set(data["agencies"]) >= {"MST", "Yolobus", "HTA"}
assert data["documents"] >= 5
def test_version_reports_pin_match(self, monkeypatch):
actual = json.loads(self._version()["body"])["corpus_version"]
monkeypatch.setenv("FPA_PINNED_CORPUS_VERSION", actual)
data = json.loads(self._version()["body"])
assert data["pinned"] == actual
assert data["matches_pin"] is True
def test_version_flags_pin_mismatch(self, monkeypatch, capsys):
monkeypatch.setenv("FPA_PINNED_CORPUS_VERSION", "deadbeefcafe")
data = json.loads(self._version()["body"])
assert data["matches_pin"] is False
assert "corpus_version_mismatch" in capsys.readouterr().out
def test_version_lists_known_retained_versions(self):
# EXP-05: the currently served corpus_version is itself a retained
# version once `make ingest` has archived it.
data = json.loads(self._version()["body"])
assert data["corpus_version"] in data["known_versions"]
class TestEmbedWidget:
def _embed(self):
return web_handler.handler(_event(method="GET", path="/embed"))
def test_embed_served(self):
resp = self._embed()
assert resp["statusCode"] == 200
assert "text/html" in resp["headers"]["content-type"]
body = resp["body"]
assert "embedded widget" in body
# The limits travel with the embed.
assert "does not decide your eligibility" in body
assert "Reference implementation" in body
# RR2: the liability/staleness frame rides above the fold, in both
# languages, not only in the footer.
assert "can be out of date" in body
assert "final eligibility decision" in body
assert "decisión final de elegibilidad" in body
def test_embed_is_frameable_main_page_is_not(self):
embed = self._embed()["headers"]
# The embed route drops the DENY and names ancestors in CSP instead.
assert "x-frame-options" not in {k.lower() for k in embed}
assert "frame-ancestors" in embed["content-security-policy"]
# The main page is still not frameable: embedding did not loosen it.
main = web_handler.handler(_event(method="GET", path="/"))["headers"]
assert main["x-frame-options"] == "DENY"
assert "frame-ancestors" not in main["content-security-policy"]
def test_embed_defaults_to_same_origin_framing(self, monkeypatch):
monkeypatch.delenv("FPA_EMBED_ANCESTORS", raising=False)
csp = self._embed()["headers"]["content-security-policy"]
assert "frame-ancestors 'self'" in csp
def test_embed_ancestor_allowlist_is_configurable(self, monkeypatch):
monkeypatch.setenv("FPA_EMBED_ANCESTORS", "https://sbmtd.gov https://mst.org")
csp = self._embed()["headers"]["content-security-policy"]
assert "frame-ancestors https://sbmtd.gov https://mst.org" in csp
def test_embed_csp_ends_with_frame_ancestors(self, monkeypatch):
# Hashing the inline blocks must not disturb the frame-ancestors tail
# the embed appends (FIX-10 keeps the framing contract intact).
monkeypatch.setenv("FPA_EMBED_ANCESTORS", "https://sbmtd.gov")
csp = self._embed()["headers"]["content-security-policy"]
assert csp.rstrip().endswith("frame-ancestors https://sbmtd.gov")
assert "unsafe-inline" not in csp
def test_embed_passes_structural_a11y(self):
from web.a11y import check_html
assert check_html(self._embed()["body"]) == []
class TestValidation:
def test_missing_body_400(self):
resp = web_handler.handler(_event(body=None))
assert resp["statusCode"] == 400
def test_malformed_json_400(self):
event = _event()
event["body"] = "not json"
resp = web_handler.handler(event)
assert resp["statusCode"] == 400
def test_non_string_question_400(self):
resp = web_handler.handler(_event(body={"question": 42}))
assert resp["statusCode"] == 400
def test_over_length_question_400(self):
resp = _post("x" * (web_handler.MAX_QUESTION_CHARS + 1))
assert resp["statusCode"] == 400
def test_oversized_body_rejected_before_parse_413(self):
event = _event()
event["body"] = "{" + "x" * (web_handler.MAX_BODY_BYTES + 1)
resp = web_handler.handler(event)
assert resp["statusCode"] == 413
class TestAnswers:
def test_answer_carries_citations_and_as_of(self):
resp = _post("Do youth ride free on Yolobus?")
assert resp["statusCode"] == 200
data = json.loads(resp["body"])
assert data["kind"] == "answered"
assert data["as_of_date"]
assert data["citations"], "an answered response must cite sources"
assert {"agency", "title", "url", "fetch_date"} <= set(data["citations"][0])
# Graded confidence signal for integrators/staff (persona research F-16).
assert data["confidence"] in {"medium", "high"}
# The answer is tied to a corpus version (persona research R2-6).
assert len(data["corpus_version"]) == 12
def test_answered_response_carries_valid_structured_contract(self):
# EXP-04: the typed payload rides alongside `answer`, validated
# against docs/answer-contract.schema.json before it is ever sent.
from assistant.contract import validate_answer_contract
resp = _post("Do youth ride free on Yolobus?")
data = json.loads(resp["body"])
assert data["structured"] is not None, "the mock model's answer should parse cleanly"
assert validate_answer_contract(data["structured"]) == []
assert data["structured"]["kind"] == "answered"
assert data["structured"]["citations"]
def test_refusal_response_structured_is_null_or_valid(self):
from assistant.contract import validate_answer_contract
resp = _post("My SSN is 123-45-6789, do I get the senior pass?")
data = json.loads(resp["body"])
if data["structured"] is not None:
assert validate_answer_contract(data["structured"]) == []
def test_pii_question_refused_and_never_echoed(self):
resp = _post("My SSN is 123-45-6789, do I get the senior pass?")
data = json.loads(resp["body"])
assert data["kind"] == "refused_input"
assert "123-45-6789" not in data["answer"]
def test_spanish_refusal_in_spanish(self):
resp = _post("Mi número de seguro social es 987-65-4321, ¿califico para el descuento?")
data = json.loads(resp["body"])
assert data["kind"] == "refused_input"
assert data["language"] == "es"
def test_uncertain_taglish_reports_top_language_and_uncertainty(self, monkeypatch):
def taglish_answer(question, **kwargs):
return AnswerResult(
question=question,
answer=(
"Batay sa mga patakaran na inilathala noong 2026-06-12, ang Regular "
"Fixed Route Single Ride fare ay $2.00 [doc:mst-fares]."
),
kind="answered",
as_of_date="2026-06-12",
citations=[
Citation(
doc_id="mst-fares",
agency="MST",
title="Fares",
url="https://mst.org/fares/",
fetch_date="2026-06-12",
)
],
)
monkeypatch.setattr(web_handler, "answer_question", taglish_answer)
data = json.loads(_post("Magkano ang pamasahe sa MST?")["body"])
assert data["language"] == "tl"
assert data["language_uncertain"] is True
assert 0 < data["language_confidence"] < 1
class TestBudget:
def test_request_budget_returns_429(self):
# Distinct questions so each is a cache miss that counts against budget.
for i in range(web_handler.REQUESTS_PER_MINUTE):
assert _post(f"What is fare number {i} on MST?")["statusCode"] == 200
resp = _post("One more distinct fare question on MST?")
assert resp["statusCode"] == 429
def test_budget_does_not_count_page_loads(self):
for _ in range(web_handler.REQUESTS_PER_MINUTE * 2):
web_handler.handler(_event(method="GET", path="/"))
assert _post("Do youth ride free on Yolobus?")["statusCode"] == 200
class TestCache:
def test_repeated_question_is_cached_and_bypasses_budget(self):
first = _post("Do youth ride free on Yolobus?")
assert first["statusCode"] == 200
# Exhaust the budget with distinct questions ...
for i in range(web_handler.REQUESTS_PER_MINUTE):
_post(f"distinct budget filler {i}?")
# ... a brand-new question is now throttled ...
assert _post("a fresh uncached question?")["statusCode"] == 429
# ... but the already-cached one still answers, free.
again = _post("Do youth ride free on Yolobus?")
assert again["statusCode"] == 200
assert again["body"] == first["body"]
def test_cache_is_case_insensitive(self):
a = _post("How much is the senior fare on SBMTD?")
b = _post("how much is the SENIOR fare on sbmtd?")
assert a["body"] == b["body"]
assert len(web_handler._ANSWER_CACHE) == 1
def test_cache_evicts_past_bound(self, monkeypatch):
monkeypatch.setattr(web_handler, "ANSWER_CACHE_SIZE", 3)
for i in range(5):
_post(f"unique question {i}?")
assert len(web_handler._ANSWER_CACHE) <= 3
class TestMultiTurn:
def test_history_parsed_and_capped(self):
raw = [{"q": f"q{i}", "a": f"a{i}"} for i in range(5)]
out = web_handler._parse_history(raw)
assert len(out) == web_handler.MAX_HISTORY_TURNS
assert out[-1] == ("q4", "a4")
def test_history_ignores_malformed(self):
assert web_handler._parse_history("nope") == []
assert web_handler._parse_history([{"q": "only q"}, {"q": 1, "a": 2}]) == []
def test_history_distinguishes_cache_entries(self):
web_handler.handler(_event(body={"question": "What is the fare?", "history": []}))
web_handler.handler(
_event(
body={
"question": "What is the fare?",
"history": [{"q": "on MST?", "a": "yes"}],
}
)
)
# Same question, different history → two distinct cache entries.
assert len(web_handler._ANSWER_CACHE) == 2
def test_delimiter_characters_cannot_collide_in_cache_key(self):
question = "What is the fare?"
web_handler.handler(
_event(body={"question": question, "history": [{"q": "a>b", "a": "c"}]})
)
web_handler.handler(
_event(body={"question": question, "history": [{"q": "a", "a": "b>c"}]})
)
# The old ``q>a`` / ``|`` join serialized both histories identically.
assert len(web_handler._ANSWER_CACHE) == 2
class TestHistoryHmac:
"""Optional forged-history hardening (FPA_HISTORY_HMAC_KEY). Off by default;
when set, only turns this server signed survive _parse_history, and /api/ask
returns the signature so the client can echo it back."""
def test_key_unset_accepts_unsigned_history(self, monkeypatch):
# Default behavior: no key, any well-formed turn is kept as context.
monkeypatch.delenv("FPA_HISTORY_HMAC_KEY", raising=False)
out = web_handler._parse_history([{"q": "on MST?", "a": "The fare is $2."}])
assert out == [("on MST?", "The fare is $2.")]
def test_key_unset_response_omits_sig(self, monkeypatch):
monkeypatch.delenv("FPA_HISTORY_HMAC_KEY", raising=False)
data = json.loads(_post("Do youth ride free on Yolobus?")["body"])
assert "sig" not in data
def test_key_set_drops_unsigned_and_tampered_turns(self, monkeypatch):
monkeypatch.setenv("FPA_HISTORY_HMAC_KEY", "test-secret")
good = web_handler._sign_turn("on MST?", "The fare is $2.")
raw = [
{"q": "on MST?", "a": "The fare is $2."}, # unsigned → dropped
{"q": "on MST?", "a": "The fare is $2.", "sig": "0" * 64}, # wrong sig → dropped
{"q": "on MST?", "a": "The fare is $2.", "sig": good}, # valid → kept
]
out = web_handler._parse_history(raw)
assert out == [("on MST?", "The fare is $2.")]
def test_key_set_drops_turn_whose_answer_was_edited(self, monkeypatch):
monkeypatch.setenv("FPA_HISTORY_HMAC_KEY", "test-secret")
sig = web_handler._sign_turn("on MST?", "The fare is $2.")
# Same signature, but the client rewrote the answer → verification fails.
out = web_handler._parse_history(
[{"q": "on MST?", "a": "Veterans ride free everywhere.", "sig": sig}]
)
assert out == []
def test_key_set_response_includes_verifiable_sig(self, monkeypatch):
monkeypatch.setenv("FPA_HISTORY_HMAC_KEY", "test-secret")
resp = _post("Do youth ride free on Yolobus?")
data = json.loads(resp["body"])
assert "sig" in data
# The returned sig is exactly what _parse_history will require on the
# round trip, so echoing {q, a, sig} back is accepted.
assert data["sig"] == web_handler._sign_turn(
"Do youth ride free on Yolobus?", data["answer"]
)
echoed = web_handler._parse_history(
[{"q": "Do youth ride free on Yolobus?", "a": data["answer"], "sig": data["sig"]}]
)
assert echoed and echoed[0][0] == "Do youth ride free on Yolobus?"
def test_sign_turn_is_length_prefixed(self, monkeypatch):
# The length prefix prevents delimiter ambiguity: ("x|y","z") must not
# collide with ("x","y|z").
monkeypatch.setenv("FPA_HISTORY_HMAC_KEY", "test-secret")
assert web_handler._sign_turn("x|y", "z") != web_handler._sign_turn("x", "y|z")
class TestFeedback:
def _fb(self, body):
return web_handler.handler(
{
"requestContext": {"http": {"method": "POST"}},
"rawPath": "/api/feedback",
"body": json.dumps(body) if body is not None else None,
}
)
def test_valid_feedback_accepted(self):
resp = self._fb({"verdict": "up", "kind": "answered", "language": "en"})
assert resp["statusCode"] == 200
def test_invalid_verdict_rejected(self):
assert self._fb({"verdict": "maybe"})["statusCode"] == 400
assert self._fb({})["statusCode"] == 400
def test_feedback_logs_no_content(self, capsys):
# Even if a client sends question/answer text, the handler must not log it.
self._fb(
{
"verdict": "down",
"kind": "answered",
"language": "es",
"question": "SECRET-Q",
"answer": "SECRET-A",
}
)
out = capsys.readouterr().out
assert "SECRET-Q" not in out and "SECRET-A" not in out
assert '"feedback": "down"' in out
def test_feedback_get_405(self):
resp = web_handler.handler(
{
"requestContext": {"http": {"method": "GET"}},
"rawPath": "/api/feedback",
"body": None,
}
)
assert resp["statusCode"] == 405