|
7 | 7 | import stamina |
8 | 8 |
|
9 | 9 | from mail_sovereignty.resolve import ( |
| 10 | + _is_ssl_error, |
| 11 | + _process_scrape_response, |
10 | 12 | build_urls, |
11 | 13 | decrypt_typo3, |
12 | 14 | detect_website_mismatch, |
@@ -1075,3 +1077,150 @@ async def test_logs_bfs_only_warning(self, tmp_path, caplog): |
1075 | 1077 | "municipalities in BFS but missing from Wikidata" in msg |
1076 | 1078 | for msg in caplog.messages |
1077 | 1079 | ) |
| 1080 | + |
| 1081 | + |
| 1082 | +# ── _process_scrape_response() ──────────────────────────────────────── |
| 1083 | + |
| 1084 | + |
| 1085 | +class TestProcessScrapeResponse: |
| 1086 | + def test_non_200_returns_unchanged(self): |
| 1087 | + r = httpx.Response(404, request=httpx.Request("GET", "https://example.ch")) |
| 1088 | + domains, redirect = _process_scrape_response(r, "example.ch", set(), None) |
| 1089 | + assert domains == set() |
| 1090 | + assert redirect is None |
| 1091 | + |
| 1092 | + def test_200_extracts_email_and_redirect(self): |
| 1093 | + r = httpx.Response( |
| 1094 | + 200, |
| 1095 | + text="Contact: info@3908.ch", |
| 1096 | + request=httpx.Request("GET", "https://www.3908.ch/"), |
| 1097 | + ) |
| 1098 | + domains, redirect = _process_scrape_response( |
| 1099 | + r, "gemeinde-saas-balen.ch", set(), None |
| 1100 | + ) |
| 1101 | + assert "3908.ch" in domains |
| 1102 | + assert redirect == "3908.ch" |
| 1103 | + |
| 1104 | + def test_200_same_domain_no_redirect(self): |
| 1105 | + r = httpx.Response( |
| 1106 | + 200, |
| 1107 | + text="Contact: info@mygemeinde.ch", |
| 1108 | + request=httpx.Request("GET", "https://www.mygemeinde.ch/"), |
| 1109 | + ) |
| 1110 | + domains, redirect = _process_scrape_response(r, "mygemeinde.ch", set(), None) |
| 1111 | + assert "mygemeinde.ch" in domains |
| 1112 | + assert redirect is None |
| 1113 | + |
| 1114 | + def test_preserves_existing_redirect(self): |
| 1115 | + r = httpx.Response( |
| 1116 | + 200, |
| 1117 | + text="Contact: info@other.ch", |
| 1118 | + request=httpx.Request("GET", "https://www.other.ch/"), |
| 1119 | + ) |
| 1120 | + domains, redirect = _process_scrape_response( |
| 1121 | + r, "example.ch", set(), "already.ch" |
| 1122 | + ) |
| 1123 | + assert "other.ch" in domains |
| 1124 | + assert redirect == "already.ch" |
| 1125 | + |
| 1126 | + |
| 1127 | +# ── _is_ssl_error() ───────────────────────────────────────────────── |
| 1128 | + |
| 1129 | + |
| 1130 | +class TestIsSslError: |
| 1131 | + def test_direct_ssl_error(self): |
| 1132 | + import ssl |
| 1133 | + |
| 1134 | + exc = ssl.SSLCertVerificationError("certificate verify failed") |
| 1135 | + assert _is_ssl_error(exc) is True |
| 1136 | + |
| 1137 | + def test_nested_ssl_error(self): |
| 1138 | + import ssl |
| 1139 | + |
| 1140 | + ssl_exc = ssl.SSLCertVerificationError("certificate verify failed") |
| 1141 | + connect_exc = httpx.ConnectError("SSL error") |
| 1142 | + connect_exc.__cause__ = ssl_exc |
| 1143 | + assert _is_ssl_error(connect_exc) is True |
| 1144 | + |
| 1145 | + def test_non_ssl_error(self): |
| 1146 | + exc = ConnectionRefusedError("Connection refused") |
| 1147 | + assert _is_ssl_error(exc) is False |
| 1148 | + |
| 1149 | + def test_string_fallback(self): |
| 1150 | + exc = Exception("CERTIFICATE_VERIFY_FAILED in handshake") |
| 1151 | + assert _is_ssl_error(exc) is True |
| 1152 | + |
| 1153 | + |
| 1154 | +# ── SSL retry in scrape_email_domains() ────────────────────────────── |
| 1155 | + |
| 1156 | + |
| 1157 | +class TestSslRetry: |
| 1158 | + @pytest.mark.asyncio |
| 1159 | + async def test_ssl_error_triggers_insecure_retry(self): |
| 1160 | + """SSL error should trigger an insecure retry that recovers.""" |
| 1161 | + import ssl |
| 1162 | + |
| 1163 | + ssl_exc = ssl.SSLCertVerificationError("certificate verify failed") |
| 1164 | + connect_exc = httpx.ConnectError("SSL handshake failed") |
| 1165 | + connect_exc.__cause__ = ssl_exc |
| 1166 | + |
| 1167 | + client = AsyncMock() |
| 1168 | + client.get = AsyncMock(side_effect=connect_exc) |
| 1169 | + |
| 1170 | + fake_response = AsyncMock() |
| 1171 | + fake_response.status_code = 200 |
| 1172 | + fake_response.text = "Contact: gemeinde@3908.ch" |
| 1173 | + fake_response.url = httpx.URL("https://www.3908.ch/") |
| 1174 | + |
| 1175 | + with patch( |
| 1176 | + "mail_sovereignty.resolve._fetch_insecure", |
| 1177 | + new_callable=AsyncMock, |
| 1178 | + return_value=fake_response, |
| 1179 | + ) as mock_fetch: |
| 1180 | + domains, redirect = await scrape_email_domains( |
| 1181 | + client, "gemeinde-saas-balen.ch" |
| 1182 | + ) |
| 1183 | + |
| 1184 | + assert "3908.ch" in domains |
| 1185 | + assert redirect == "3908.ch" |
| 1186 | + mock_fetch.assert_called() |
| 1187 | + |
| 1188 | + @pytest.mark.asyncio |
| 1189 | + async def test_non_ssl_connect_error_no_retry(self): |
| 1190 | + """Non-SSL ConnectError should not trigger insecure retry.""" |
| 1191 | + connect_exc = httpx.ConnectError("Connection refused") |
| 1192 | + |
| 1193 | + client = AsyncMock() |
| 1194 | + client.get = AsyncMock(side_effect=connect_exc) |
| 1195 | + |
| 1196 | + with patch( |
| 1197 | + "mail_sovereignty.resolve._fetch_insecure", |
| 1198 | + new_callable=AsyncMock, |
| 1199 | + ) as mock_fetch: |
| 1200 | + domains, redirect = await scrape_email_domains(client, "example.ch") |
| 1201 | + |
| 1202 | + assert domains == set() |
| 1203 | + assert redirect is None |
| 1204 | + mock_fetch.assert_not_called() |
| 1205 | + |
| 1206 | + @pytest.mark.asyncio |
| 1207 | + async def test_ssl_retry_failure_continues(self): |
| 1208 | + """If insecure retry also fails, scrape should continue gracefully.""" |
| 1209 | + import ssl |
| 1210 | + |
| 1211 | + ssl_exc = ssl.SSLCertVerificationError("certificate verify failed") |
| 1212 | + connect_exc = httpx.ConnectError("SSL handshake failed") |
| 1213 | + connect_exc.__cause__ = ssl_exc |
| 1214 | + |
| 1215 | + client = AsyncMock() |
| 1216 | + client.get = AsyncMock(side_effect=connect_exc) |
| 1217 | + |
| 1218 | + with patch( |
| 1219 | + "mail_sovereignty.resolve._fetch_insecure", |
| 1220 | + new_callable=AsyncMock, |
| 1221 | + side_effect=httpx.ConnectError("still broken"), |
| 1222 | + ): |
| 1223 | + domains, redirect = await scrape_email_domains(client, "example.ch") |
| 1224 | + |
| 1225 | + assert domains == set() |
| 1226 | + assert redirect is None |
0 commit comments