| 1 | """ |
| 2 | Tests for ingest.source_validator - neutrality rule |
| 3 | |
| 4 | Every stored fact MUST carry a source_url whose host is an allowlisted .gov domain. |
| 5 | """ |
| 6 | |
| 7 | import sqlite3 |
| 8 | import tempfile |
| 9 | import os |
| 10 | |
| 11 | import pytest |
| 12 | |
| 13 | from ingest.source_validator import ( |
| 14 | ALLOWLISTED_HOSTS, |
| 15 | SourceURLError, |
| 16 | is_allowlisted, |
| 17 | validate_source_url, |
| 18 | ) |
| 19 | from ingest.db import get_connection, init_schema |
| 20 | |
| 21 | |
| 22 | |
| 23 | class TestIsAllowlisted: |
| 24 | def test_explicit_allowlist_hosts(self): |
| 25 | for host in ALLOWLISTED_HOSTS: |
| 26 | url = f"https://{host}/some/path" |
| 27 | assert is_allowlisted(url), f"Expected {host!r} to be allowlisted" |
| 28 | |
| 29 | def test_any_gov_domain_is_allowed(self): |
| 30 | gov_urls = [ |
| 31 | "https://www.usaspending.gov/award/CONT_AWD_N0001923C0001", |
| 32 | "https://congress.gov/bill/119th-congress/house-bill/1", |
| 33 | "https://fiscaldata.treasury.gov/datasets/debt-to-the-penny/", |
| 34 | "https://clerk.house.gov/evs/2026/ROLL_001.xml", |
| 35 | "https://www.senate.gov/legislative/LIS/roll_call_votes/vote1191/vote_119_1_00001.xml", |
| 36 | "https://leg.colorado.gov/bills/hb25-1001", |
| 37 | "https://legiscan.gov/somepage", |
| 38 | "https://www.congress.gov/bill/119th-congress/senate-bill/99", |
| 39 | ] |
| 40 | for url in gov_urls: |
| 41 | assert is_allowlisted(url), f"Expected .gov URL to be allowed: {url}" |
| 42 | |
| 43 | def test_non_gov_domains_rejected(self): |
| 44 | bad_urls = [ |
| 45 | "https://www.nytimes.com/article/congress-bill", |
| 46 | "https://propublica.org/data", |
| 47 | "https://govtrack.us/congress/bills", |
| 48 | "https://ballotpedia.org/H.R._1", |
| 49 | "https://en.wikipedia.org/wiki/U.S._Congress", |
| 50 | "https://politico.com/story", |
| 51 | "https://api.example.com/bills", |
| 52 | "", |
| 53 | ] |
| 54 | for url in bad_urls: |
| 55 | assert not is_allowlisted(url), f"Expected {url!r} to be rejected" |
| 56 | |
| 57 | def test_empty_string_rejected(self): |
| 58 | assert not is_allowlisted("") |
| 59 | |
| 60 | def test_none_like_string_rejected(self): |
| 61 | assert not is_allowlisted("None") |
| 62 | |
| 63 | def test_subdomain_gov_allowed(self): |
| 64 | assert is_allowlisted("https://api.congress.gov/v3/bill") |
| 65 | assert is_allowlisted("https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/debt_to_penny") |
| 66 | |
| 67 | def test_gov_in_path_not_in_host_rejected(self): |
| 68 | assert not is_allowlisted("https://example.com/gov/bills") |
| 69 | |
| 70 | |
| 71 | |
| 72 | class TestValidateSourceUrl: |
| 73 | def test_valid_gov_url_does_not_raise(self): |
| 74 | validate_source_url("https://www.usaspending.gov/award/CONT_AWD_TEST") |
| 75 | validate_source_url("https://congress.gov/bill/119th-congress/house-bill/1") |
| 76 | |
| 77 | def test_invalid_url_raises_source_url_error(self): |
| 78 | with pytest.raises(SourceURLError): |
| 79 | validate_source_url("https://www.reuters.com/article/xyz") |
| 80 | |
| 81 | def test_empty_url_raises(self): |
| 82 | with pytest.raises(SourceURLError): |
| 83 | validate_source_url("") |
| 84 | |
| 85 | def test_non_gov_subdomain_raises(self): |
| 86 | with pytest.raises(SourceURLError): |
| 87 | validate_source_url("https://api.example.io/bills") |
| 88 | |
| 89 | def test_error_message_contains_host(self): |
| 90 | with pytest.raises(SourceURLError, match="nytimes.com"): |
| 91 | validate_source_url("https://www.nytimes.com/article/bill") |
| 92 | |
| 93 | |
| 94 | |
| 95 | class TestDatabaseSourceUrlConstraint: |
| 96 | """ |
| 97 | Integration test: simulate writing rows to SQLite and verify that any row |
| 98 | whose source_url is not allowlisted would have been caught by validate_source_url |
| 99 | before insertion. |
| 100 | """ |
| 101 | |
| 102 | def _make_db(self): |
| 103 | tmp = tempfile.mktemp(suffix=".db") |
| 104 | conn = get_connection(tmp) |
| 105 | init_schema(conn) |
| 106 | return conn, tmp |
| 107 | |
| 108 | def test_awards_with_valid_source_url_stored_successfully(self): |
| 109 | conn, tmp = self._make_db() |
| 110 | source_url = "https://www.usaspending.gov/award/CONT_AWD_TEST001" |
| 111 | validate_source_url(source_url) |
| 112 | conn.execute( |
| 113 | "INSERT INTO awards (source, external_id, agency, recipient, amount, action_date, program, source_url) " |
| 114 | "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", |
| 115 | ("usaspending", "TEST001", "DoD", "ACME Corp", 1_000_000, "2026-06-25", "contract", source_url), |
| 116 | ) |
| 117 | conn.commit() |
| 118 | row = conn.execute("SELECT source_url FROM awards WHERE external_id='TEST001'").fetchone() |
| 119 | assert row is not None |
| 120 | assert is_allowlisted(row["source_url"]) |
| 121 | conn.close() |
| 122 | os.unlink(tmp) |
| 123 | |
| 124 | def test_awards_with_invalid_source_url_blocked(self): |
| 125 | conn, tmp = self._make_db() |
| 126 | bad_url = "https://www.politico.com/award/XYZ" |
| 127 | with pytest.raises(SourceURLError): |
| 128 | validate_source_url(bad_url) |
| 129 | conn.execute( |
| 130 | "INSERT INTO awards (source, external_id, agency, recipient, amount, action_date, program, source_url) " |
| 131 | "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", |
| 132 | ("test", "BAD001", "Agency", "Bad Corp", 100, "2026-06-25", "contract", bad_url), |
| 133 | ) |
| 134 | conn.close() |
| 135 | os.unlink(tmp) |
| 136 | |
| 137 | def test_all_awards_from_usaspending_have_gov_source_url(self): |
| 138 | """ |
| 139 | Simulate a batch of awards as the poller would produce them and confirm |
| 140 | every source_url passes the allowlist check. |
| 141 | """ |
| 142 | internal_ids = [ |
| 143 | "CONT_AWD_N0001923C0001", |
| 144 | "CONT_AWD_W912HV23C0001", |
| 145 | "ASST_NON_2023MU00009", |
| 146 | ] |
| 147 | for iid in internal_ids: |
| 148 | url = f"https://www.usaspending.gov/award/{iid}" |
| 149 | assert is_allowlisted(url), f"USAspending award URL failed allowlist: {url}" |
| 150 | |
| 151 | def test_bills_from_congress_have_gov_source_url(self): |
| 152 | urls = [ |
| 153 | "https://www.congress.gov/bill/119th-congress/house-bill/1", |
| 154 | "https://www.congress.gov/bill/119th-congress/senate-bill/99", |
| 155 | ] |
| 156 | for url in urls: |
| 157 | assert is_allowlisted(url) |
| 158 | |
| 159 | def test_fiscal_from_treasury_has_gov_source_url(self): |
| 160 | url = "https://fiscaldata.treasury.gov/datasets/debt-to-the-penny/debt-to-the-penny" |
| 161 | assert is_allowlisted(url) |
| 162 | |
| 163 | def test_house_votes_have_gov_source_url(self): |
| 164 | url = "https://clerk.house.gov/evs/2026/ROLL_001.xml" |
| 165 | assert is_allowlisted(url) |
| 166 | |
| 167 | def test_senate_votes_have_gov_source_url(self): |
| 168 | url = "https://www.senate.gov/legislative/LIS/roll_call_votes/vote1191/vote_119_1_00001.xml" |
| 169 | assert is_allowlisted(url) |