|
1 | 1 | """Tests for the download/language functionality of LanguageTool.""" |
2 | 2 |
|
| 3 | +import hashlib |
3 | 4 | import io |
| 5 | +import re |
| 6 | +import zipfile |
| 7 | +from collections.abc import Iterator |
4 | 8 | from unittest.mock import MagicMock, patch |
5 | 9 |
|
6 | 10 | import pytest |
7 | 11 |
|
8 | | -from language_tool_python.download_lt import LocalLanguageTool |
| 12 | +from language_tool_python.download_lt import ( |
| 13 | + LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR, |
| 14 | + LTP_DOWNLOAD_SHA256_ENV_VAR, |
| 15 | + LocalLanguageTool, |
| 16 | +) |
9 | 17 | from language_tool_python.exceptions import LanguageToolError, PathError |
10 | 18 |
|
11 | 19 |
|
| 20 | +class MockDownloadResponse: |
| 21 | + """ |
| 22 | + Minimal requests.Response replacement for download tests. |
| 23 | + """ |
| 24 | + |
| 25 | + def __init__(self, payload: bytes, status_code: int = 200) -> None: |
| 26 | + self.payload = payload |
| 27 | + self.status_code = status_code |
| 28 | + self.headers = {"Content-Length": str(len(payload))} |
| 29 | + |
| 30 | + def iter_content(self, chunk_size: int) -> Iterator[bytes]: |
| 31 | + for index in range(0, len(self.payload), chunk_size): |
| 32 | + yield self.payload[index : index + chunk_size] |
| 33 | + |
| 34 | + |
| 35 | +def make_zip_payload(files: dict[str, bytes]) -> bytes: |
| 36 | + """ |
| 37 | + Create an in-memory ZIP payload for download tests. |
| 38 | + """ |
| 39 | + buffer = io.BytesIO() |
| 40 | + with zipfile.ZipFile(buffer, "w") as zip_file: |
| 41 | + for filename, payload in files.items(): |
| 42 | + zip_file.writestr(filename, payload) |
| 43 | + return buffer.getvalue() |
| 44 | + |
| 45 | + |
12 | 46 | def test_install_inexistent_version() -> None: |
13 | 47 | """ |
14 | 48 | Test that attempting to download a non-existent LanguageTool version raises an error. |
@@ -101,6 +135,119 @@ def test_http_get_other_error_codes() -> None: |
101 | 135 | local_language_tool._get_remote_zip(out_file) |
102 | 136 |
|
103 | 137 |
|
| 138 | +def test_http_get_verifies_configured_sha256( |
| 139 | + monkeypatch: pytest.MonkeyPatch, |
| 140 | +) -> None: |
| 141 | + """ |
| 142 | + Test that downloads are accepted when the configured SHA-256 matches. |
| 143 | + """ |
| 144 | + payload = make_zip_payload( |
| 145 | + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} |
| 146 | + ) |
| 147 | + local_language_tool = LocalLanguageTool.from_version_name() |
| 148 | + suffix = ( |
| 149 | + re.sub(r"[^A-Za-z0-9]+", "_", local_language_tool.version_name) |
| 150 | + .strip("_") |
| 151 | + .upper() |
| 152 | + ) |
| 153 | + version_env_var = f"LTP_DOWNLOAD_SHA256_{suffix}" |
| 154 | + monkeypatch.setenv( |
| 155 | + version_env_var, |
| 156 | + hashlib.sha256(payload).hexdigest(), |
| 157 | + ) |
| 158 | + |
| 159 | + with patch( |
| 160 | + "language_tool_python.download_lt.requests.get", |
| 161 | + return_value=MockDownloadResponse(payload), |
| 162 | + ): |
| 163 | + out_file = io.BytesIO() |
| 164 | + with local_language_tool._get_remote_zip(out_file) as zip_file: |
| 165 | + assert zip_file.namelist() == [ |
| 166 | + "LanguageTool-6.9-SNAPSHOT/languagetool-server.jar" |
| 167 | + ] |
| 168 | + |
| 169 | + |
| 170 | +def test_http_get_uses_integrity_manifest_sha256( |
| 171 | + monkeypatch: pytest.MonkeyPatch, |
| 172 | +) -> None: |
| 173 | + """ |
| 174 | + Test that bundled integrity.toml checksums are used when no env var is set. |
| 175 | + """ |
| 176 | + payload = make_zip_payload({"LanguageTool-4.0/languagetool-server.jar": b"jar"}) |
| 177 | + local_language_tool = LocalLanguageTool.from_version_name("4.0") |
| 178 | + monkeypatch.delenv(LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR, raising=False) |
| 179 | + monkeypatch.delenv(LTP_DOWNLOAD_SHA256_ENV_VAR, raising=False) |
| 180 | + monkeypatch.delenv("LTP_DOWNLOAD_SHA256_4_0", raising=False) |
| 181 | + |
| 182 | + with ( |
| 183 | + patch( |
| 184 | + "language_tool_python.download_lt.requests.get", |
| 185 | + return_value=MockDownloadResponse(payload), |
| 186 | + ), |
| 187 | + pytest.raises(PathError, match="checksum mismatch"), |
| 188 | + ): |
| 189 | + local_language_tool._get_remote_zip(io.BytesIO()) |
| 190 | + |
| 191 | + |
| 192 | +def test_http_get_rejects_sha256_mismatch( |
| 193 | + monkeypatch: pytest.MonkeyPatch, |
| 194 | +) -> None: |
| 195 | + """ |
| 196 | + Test that downloads are rejected when the configured SHA-256 mismatches. |
| 197 | + """ |
| 198 | + payload = make_zip_payload( |
| 199 | + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} |
| 200 | + ) |
| 201 | + local_language_tool = LocalLanguageTool.from_version_name() |
| 202 | + suffix = ( |
| 203 | + re.sub(r"[^A-Za-z0-9]+", "_", local_language_tool.version_name) |
| 204 | + .strip("_") |
| 205 | + .upper() |
| 206 | + ) |
| 207 | + version_env_var = f"LTP_DOWNLOAD_SHA256_{suffix}" |
| 208 | + monkeypatch.setenv( |
| 209 | + version_env_var, |
| 210 | + "0" * 64, |
| 211 | + ) |
| 212 | + |
| 213 | + with ( |
| 214 | + patch( |
| 215 | + "language_tool_python.download_lt.requests.get", |
| 216 | + return_value=MockDownloadResponse(payload), |
| 217 | + ), |
| 218 | + pytest.raises(PathError, match="checksum mismatch"), |
| 219 | + ): |
| 220 | + out_file = io.BytesIO() |
| 221 | + local_language_tool._get_remote_zip(out_file) |
| 222 | + |
| 223 | + |
| 224 | +def test_http_get_bypass_skips_sha256_verification( |
| 225 | + monkeypatch: pytest.MonkeyPatch, |
| 226 | +) -> None: |
| 227 | + """ |
| 228 | + Test that the bypass disables SHA-256 verification. |
| 229 | + """ |
| 230 | + payload = make_zip_payload( |
| 231 | + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} |
| 232 | + ) |
| 233 | + local_language_tool = LocalLanguageTool.from_version_name() |
| 234 | + monkeypatch.setenv(LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR, "true") |
| 235 | + monkeypatch.setenv(LTP_DOWNLOAD_SHA256_ENV_VAR, "0" * 64) |
| 236 | + |
| 237 | + with ( |
| 238 | + patch( |
| 239 | + "language_tool_python.download_lt.requests.get", |
| 240 | + return_value=MockDownloadResponse(payload), |
| 241 | + ), |
| 242 | + pytest.warns(RuntimeWarning, match="Verified downloads are bypassed"), |
| 243 | + ): |
| 244 | + out_file = io.BytesIO() |
| 245 | + with local_language_tool._get_remote_zip(out_file) as zip_file: |
| 246 | + assert zip_file.namelist() == [ |
| 247 | + "LanguageTool-6.9-SNAPSHOT/languagetool-server.jar" |
| 248 | + ] |
| 249 | + |
| 250 | + |
104 | 251 | def test_install_oldest_supported_version() -> None: |
105 | 252 | """ |
106 | 253 | Test that downloading the oldest supported LanguageTool version works correctly. |
|
0 commit comments