webget-cli 0.6.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {webget_cli-0.6.0 → webget_cli-0.7.1}/PKG-INFO +34 -1
  2. {webget_cli-0.6.0 → webget_cli-0.7.1}/README.md +31 -0
  3. {webget_cli-0.6.0 → webget_cli-0.7.1}/pyproject.toml +4 -2
  4. webget_cli-0.7.1/tests/test_adversarial_auth.py +145 -0
  5. webget_cli-0.7.1/tests/test_adversarial_cache.py +179 -0
  6. webget_cli-0.7.1/tests/test_adversarial_concurrency.py +54 -0
  7. webget_cli-0.7.1/tests/test_adversarial_http.py +153 -0
  8. webget_cli-0.7.1/tests/test_adversarial_mcp.py +188 -0
  9. webget_cli-0.7.1/tests/test_adversarial_ssrf.py +132 -0
  10. webget_cli-0.7.1/tests/test_auth_review.py +109 -0
  11. webget_cli-0.7.1/tests/test_browser_ssrf.py +149 -0
  12. webget_cli-0.7.1/tests/test_cache_review.py +114 -0
  13. webget_cli-0.7.1/tests/test_concurrency_review.py +82 -0
  14. webget_cli-0.7.1/tests/test_firecrawl_policy.py +89 -0
  15. webget_cli-0.7.1/tests/test_integration_ladder.py +219 -0
  16. webget_cli-0.7.1/tests/test_mcp_leak_review.py +142 -0
  17. webget_cli-0.7.1/tests/test_mcp_server.py +102 -0
  18. webget_cli-0.7.1/tests/test_mcp_smoke.py +45 -0
  19. webget_cli-0.7.1/tests/test_security_review.py +192 -0
  20. webget_cli-0.7.1/tests/test_size_review.py +80 -0
  21. {webget_cli-0.6.0 → webget_cli-0.7.1}/tests/test_webget.py +88 -9
  22. {webget_cli-0.6.0 → webget_cli-0.7.1}/webget_cli.egg-info/PKG-INFO +34 -1
  23. webget_cli-0.7.1/webget_cli.egg-info/SOURCES.txt +29 -0
  24. {webget_cli-0.6.0 → webget_cli-0.7.1}/webget_cli.egg-info/entry_points.txt +1 -0
  25. {webget_cli-0.6.0 → webget_cli-0.7.1}/webget_cli.egg-info/requires.txt +3 -0
  26. {webget_cli-0.6.0 → webget_cli-0.7.1}/webget_cli.egg-info/top_level.txt +1 -0
  27. {webget_cli-0.6.0 → webget_cli-0.7.1}/webget_cli.py +397 -98
  28. webget_cli-0.7.1/webget_mcp.py +151 -0
  29. webget_cli-0.6.0/webget_cli.egg-info/SOURCES.txt +0 -11
  30. {webget_cli-0.6.0 → webget_cli-0.7.1}/LICENSE +0 -0
  31. {webget_cli-0.6.0 → webget_cli-0.7.1}/setup.cfg +0 -0
  32. {webget_cli-0.6.0 → webget_cli-0.7.1}/webget_cli.egg-info/dependency_links.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webget-cli
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
5
5
  Author-email: David Tarigan <tarigansdavid@gmail.com>
6
6
  License: MIT
@@ -25,6 +25,8 @@ Requires-Dist: trafilatura
25
25
  Requires-Dist: html2text
26
26
  Provides-Extra: browser
27
27
  Requires-Dist: crawl4ai>=0.9; extra == "browser"
28
+ Provides-Extra: mcp
29
+ Requires-Dist: fastmcp>=2; extra == "mcp"
28
30
  Provides-Extra: dev
29
31
  Requires-Dist: pytest>=8; extra == "dev"
30
32
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -210,6 +212,37 @@ fetches never collide. Failures are never cached.
210
212
  > **Privacy note:** cached content is plaintext JSON on disk. If you fetch
211
213
  > authenticated/personal pages, use `--no-cache`.
212
214
 
215
+ ## MCP server
216
+
217
+ `webget_mcp.py` exposes the same ladder as an MCP server (`search`,
218
+ `fetch`, `search_fetch`), so agents like opencode can search and scrape
219
+ without API keys:
220
+
221
+ ```bash
222
+ pip install "webget-cli[mcp]"
223
+ ```
224
+
225
+ Register as a local MCP server in opencode:
226
+
227
+ ```jsonc
228
+ {
229
+ "mcp": {
230
+ "webget": {
231
+ "type": "local",
232
+ "command": ["webget-mcp"],
233
+ "enabled": true
234
+ }
235
+ }
236
+ }
237
+ ```
238
+
239
+ Then prompt with `use webget` for search and scrape tasks. Run the server
240
+ standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
241
+
242
+ > **Limitation:** MCP tools do not expose `--profile`/`--cookies`, so
243
+ > authenticated pages are out of scope for the MCP server. Use the CLI
244
+ > (`webget login`, `webget u --profile ...`) for session-based fetching.
245
+
213
246
  ## Development
214
247
 
215
248
  ```bash
@@ -178,6 +178,37 @@ fetches never collide. Failures are never cached.
178
178
  > **Privacy note:** cached content is plaintext JSON on disk. If you fetch
179
179
  > authenticated/personal pages, use `--no-cache`.
180
180
 
181
+ ## MCP server
182
+
183
+ `webget_mcp.py` exposes the same ladder as an MCP server (`search`,
184
+ `fetch`, `search_fetch`), so agents like opencode can search and scrape
185
+ without API keys:
186
+
187
+ ```bash
188
+ pip install "webget-cli[mcp]"
189
+ ```
190
+
191
+ Register as a local MCP server in opencode:
192
+
193
+ ```jsonc
194
+ {
195
+ "mcp": {
196
+ "webget": {
197
+ "type": "local",
198
+ "command": ["webget-mcp"],
199
+ "enabled": true
200
+ }
201
+ }
202
+ }
203
+ ```
204
+
205
+ Then prompt with `use webget` for search and scrape tasks. Run the server
206
+ standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
207
+
208
+ > **Limitation:** MCP tools do not expose `--profile`/`--cookies`, so
209
+ > authenticated pages are out of scope for the MCP server. Use the CLI
210
+ > (`webget login`, `webget u --profile ...`) for session-based fetching.
211
+
181
212
  ## Development
182
213
 
183
214
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "webget-cli"
7
- version = "0.6.0"
7
+ version = "0.7.1"
8
8
  description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -35,13 +35,15 @@ Changelog = "https://github.com/DavidPandleton/webget/blob/main/CHANGELOG.md"
35
35
 
36
36
  [project.optional-dependencies]
37
37
  browser = ["crawl4ai>=0.9"]
38
+ mcp = ["fastmcp>=2"]
38
39
  dev = ["pytest>=8", "ruff>=0.6"]
39
40
 
40
41
  [project.scripts]
41
42
  webget = "webget_cli:main"
43
+ webget-mcp = "webget_mcp:main"
42
44
 
43
45
  [tool.setuptools]
44
- py-modules = ["webget_cli"]
46
+ py-modules = ["webget_cli", "webget_mcp"]
45
47
 
46
48
  [tool.pytest.ini_options]
47
49
  testpaths = ["tests"]
@@ -0,0 +1,145 @@
1
+ """Adversarial auth + cookie isolation tests: profiles, storage state,
2
+ logout domain scoping, session reuse, corrupted state."""
3
+
4
+ import asyncio
5
+ import json
6
+ import os
7
+
8
+ import pytest
9
+
10
+ import webget_cli as webget
11
+
12
+
13
+ async def _fetch(url, **kw):
14
+ return await webget.scrape_many([url], max_chars=2000, no_cache=True, **kw)
15
+
16
+
17
+ def _one(res):
18
+ return res[next(iter(res))]
19
+
20
+
21
+ def _cookie(name, domain, expires=-1):
22
+ return {
23
+ "name": name,
24
+ "value": "v",
25
+ "domain": domain,
26
+ "path": "/",
27
+ "secure": False,
28
+ "expires": expires,
29
+ }
30
+
31
+
32
+ class TestProfileSafety:
33
+ def test_profile_dir_rejects_traversal(self):
34
+ for name in ("../../etc", "..", ".", "a/b", "a\\b", "~evil", "", "/abs"):
35
+ with pytest.raises(SystemExit):
36
+ webget.profile_dir(name)
37
+
38
+
39
+ class TestStorageState:
40
+ def test_load_profile_cookies_missing_file(self, isolated_env):
41
+ assert webget.load_profile_cookies("ghost") is None
42
+
43
+ def test_load_profile_cookies_corrupt(self, isolated_env):
44
+ p = webget.profile_state_path("campus")
45
+ os.makedirs(os.path.dirname(p), exist_ok=True)
46
+ with open(p, "w") as f:
47
+ f.write("{not json")
48
+ assert webget.load_profile_cookies("campus") is None
49
+
50
+ def test_load_profile_cookies_ok(self, isolated_env):
51
+ p = webget.profile_state_path("campus")
52
+ os.makedirs(os.path.dirname(p), exist_ok=True)
53
+ with open(p, "w") as f:
54
+ json.dump({"cookies": [_cookie("s", ".campus.example")]}, f)
55
+ ck = webget.load_profile_cookies("campus")
56
+ assert ck and ck[0]["name"] == "s"
57
+
58
+
59
+ class TestCookieDomainMatching:
60
+ def test_belongs_to_subdomains(self):
61
+ host = "campus.example"
62
+ assert webget._cookie_belongs_to("campus.example", host)
63
+ assert webget._cookie_belongs_to(".campus.example", host)
64
+ assert webget._cookie_belongs_to(".api.campus.example", host)
65
+ assert webget._cookie_belongs_to("api.campus.example", host)
66
+ # not unrelated, not suffix-trap
67
+ assert not webget._cookie_belongs_to("github.com", host)
68
+ assert not webget._cookie_belongs_to("notevil.com", host)
69
+ assert not webget._cookie_belongs_to("example.com.evil.com", host)
70
+
71
+ def test_domain_match_public(self):
72
+ assert webget._domain_match(".example.com", "api.example.com")
73
+ assert not webget._domain_match(".example.com", "example.com.evil.com")
74
+
75
+
76
+ class TestLogoutScoping:
77
+ def test_logout_prunes_only_target_domain(self, isolated_env):
78
+ """Storage-state pruning: logout campus.example keeps github cookies."""
79
+ state = {
80
+ "cookies": [
81
+ _cookie("campus", ".campus.example"),
82
+ _cookie("api", ".api.campus.example"),
83
+ _cookie("gh", ".github.com"),
84
+ ]
85
+ }
86
+ new_state, removed = webget._prune_storage_cookies(state, "campus.example")
87
+ assert removed == 2
88
+ domains = {c["domain"] for c in new_state["cookies"]}
89
+ assert ".campus.example" not in domains
90
+ assert ".api.campus.example" not in domains
91
+ assert ".github.com" in domains # unrelated survives
92
+
93
+ def test_logout_preserves_unrelated_domains_file(self, isolated_env):
94
+ """End-to-end through _write_json: the file on disk is updated."""
95
+ state = {"cookies": [_cookie("a", ".a.com"), _cookie("b", ".b.com")]}
96
+ p = webget.profile_state_path("work")
97
+ os.makedirs(os.path.dirname(p), exist_ok=True)
98
+ with open(p, "w") as f:
99
+ json.dump(state, f)
100
+ with open(p) as f:
101
+ loaded = json.load(f)
102
+ new_state, removed = webget._prune_storage_cookies(loaded, "a.com")
103
+ assert removed == 1
104
+ domains = {c["domain"] for c in new_state["cookies"]}
105
+ assert ".a.com" not in domains and ".b.com" in domains
106
+
107
+
108
+ class TestSessionReuse:
109
+ def test_profile_cookies_sent_to_gated_page(self, fresh_cache, isolated_env):
110
+ server = fresh_cache
111
+ """Cookie-gated /cookie-gated returns 403 without a cookie, success with."""
112
+ res = asyncio.run(_fetch(server.url("/cookie-gated")))
113
+ assert _one(res)["status"] == "blocked"
114
+
115
+ # seed a profile with a cookie for the server host
116
+ host = server.host
117
+ p = webget.profile_state_path("local")
118
+ os.makedirs(os.path.dirname(p), exist_ok=True)
119
+ with open(p, "w") as f:
120
+ json.dump({"cookies": [_cookie("session", host)]}, f)
121
+
122
+ res = asyncio.run(_fetch(server.url("/cookie-gated"), profile="local"))
123
+ assert _one(res)["status"] == "success"
124
+
125
+ def test_explicit_cookies_win_over_profile(self, fresh_cache, isolated_env):
126
+ server = fresh_cache
127
+ ck = [_cookie("session", server.host)]
128
+ res = asyncio.run(_fetch(server.url("/cookie-gated"), cookies=ck))
129
+ assert _one(res)["status"] == "success"
130
+
131
+
132
+ class TestProfileMeta:
133
+ def test_corrupt_state_reports_corrupt(self, isolated_env):
134
+ d = webget.profile_dir("broken")
135
+ os.makedirs(d, exist_ok=True)
136
+ with open(webget.profile_state_path("broken"), "w") as f:
137
+ f.write("{{{{")
138
+ assert webget._profile_meta("broken")["status"] == "corrupt"
139
+
140
+ def test_expired_cookies_reported_expired(self, isolated_env):
141
+ d = webget.profile_dir("old")
142
+ os.makedirs(d, exist_ok=True)
143
+ with open(webget.profile_state_path("old"), "w") as f:
144
+ json.dump({"cookies": [_cookie("s", ".x.com", expires=1000000000)]}, f)
145
+ assert webget._profile_meta("old")["status"] == "expired"
@@ -0,0 +1,179 @@
1
+ """Adversarial cache tests: isolation keys, corruption, concurrency, atomicity."""
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ import time
7
+
8
+ import webget_cli as webget
9
+
10
+
11
+ class TestCacheKeys:
12
+ def test_profile_isolation(self):
13
+ a = webget._cache_path("https://x.com", None, None, 1000, "p1")
14
+ b = webget._cache_path("https://x.com", None, None, 1000, "p2")
15
+ assert a != b
16
+
17
+ def test_anonymous_vs_profile(self):
18
+ a = webget._cache_path("https://x.com", None, None, 1000, None)
19
+ b = webget._cache_path("https://x.com", None, None, 1000, "p1")
20
+ assert a != b
21
+
22
+ def test_headers_in_key(self):
23
+ a = webget._cache_path("https://x.com", None, {"A": "1"}, 1000, None)
24
+ b = webget._cache_path("https://x.com", None, {"A": "2"}, 1000, None)
25
+ assert a != b
26
+
27
+ def test_cookies_in_key(self):
28
+ ck = [{"name": "s", "value": "v", "domain": "x.com"}]
29
+ a = webget._cache_path("https://x.com", None, None, 1000, None)
30
+ b = webget._cache_path("https://x.com", ck, None, 1000, None)
31
+ assert a != b
32
+
33
+ def test_cookie_order_independent(self):
34
+ c1 = [
35
+ {"name": "a", "value": "1", "domain": "x.com"},
36
+ {"name": "b", "value": "2", "domain": "x.com"},
37
+ ]
38
+ c2 = [
39
+ {"name": "b", "value": "2", "domain": "x.com"},
40
+ {"name": "a", "value": "1", "domain": "x.com"},
41
+ ]
42
+ assert webget._cache_path("https://x.com", c1, None, 1000, None) == webget._cache_path(
43
+ "https://x.com", c2, None, 1000, None
44
+ )
45
+
46
+ def test_max_chars_in_key(self):
47
+ assert webget._cache_path("https://x.com", None, None, 500, None) != webget._cache_path(
48
+ "https://x.com", None, None, 900, None
49
+ )
50
+
51
+
52
+ class TestCacheCorruption:
53
+ def test_corrupt_cache_returns_none(self, isolated_env):
54
+ p = webget._cache_path("https://x.com", None, None, 1000, None)
55
+ os.makedirs(os.path.dirname(p), exist_ok=True)
56
+ with open(p, "w") as f:
57
+ f.write("{oops")
58
+ assert webget.cache_get("https://x.com", None, None, 1000, 3600) is None
59
+
60
+ def test_expired_cache_returns_none(self, isolated_env):
61
+ p = webget._cache_path("https://x.com", None, None, 1000, None)
62
+ os.makedirs(os.path.dirname(p), exist_ok=True)
63
+ with open(p, "w") as f:
64
+ json.dump({"status": "success"}, f)
65
+ os.utime(p, (time.time() - 7200, time.time() - 7200)) # 2h old, ttl 3600
66
+ assert webget.cache_get("https://x.com", None, None, 1000, 3600) is None
67
+
68
+
69
+ class TestCacheConcurrency:
70
+ def test_concurrent_writes_never_corrupt(self, isolated_env):
71
+ """20 threads writing the same cache entry: file must stay valid JSON."""
72
+ url = "https://concurrent.test/page"
73
+ results = []
74
+
75
+ def writer(i):
76
+ try:
77
+ webget.cache_put(url, None, None, 1000, {"status": "success", "i": i}, None)
78
+ results.append(("ok", i))
79
+ except Exception as e: # noqa: BLE001
80
+ results.append(("err", str(e)))
81
+
82
+ threads = [threading.Thread(target=writer, args=(i,)) for i in range(20)]
83
+ for t in threads:
84
+ t.start()
85
+ for t in threads:
86
+ t.join()
87
+
88
+ assert not [r for r in results if r[0] == "err"]
89
+ p = webget._cache_path(url, None, None, 1000, None)
90
+ with open(p) as f:
91
+ data = json.load(f) # must parse
92
+ assert data["status"] == "success"
93
+
94
+ def test_concurrent_writers_use_unique_tmp(self, isolated_env):
95
+ """Deterministic regression (real bug caught 2026-08-08): concurrent
96
+ writers must NEVER share a tmp path. A shared '<path>.tmp' lets
97
+ threads interleave bytes in one file, so the atomic rename publishes
98
+ a corrupt document (observed as 'Extra data' JSONDecodeError). This
99
+ spies on os.replace instead of hoping the race window opens."""
100
+ url = "https://unique-tmp.test/page"
101
+ real_replace = os.replace
102
+ seen = []
103
+ lock = threading.Lock()
104
+
105
+ def spy(src, dst):
106
+ with lock:
107
+ seen.append(src)
108
+ return real_replace(src, dst)
109
+
110
+ os.replace = spy
111
+ try:
112
+ threads = [
113
+ threading.Thread(
114
+ target=webget.cache_put,
115
+ args=(url, None, None, 1000, {"status": "success", "i": i}, None),
116
+ )
117
+ for i in range(20)
118
+ ]
119
+ for t in threads:
120
+ t.start()
121
+ for t in threads:
122
+ t.join()
123
+ finally:
124
+ os.replace = real_replace
125
+
126
+ final = webget._cache_path(url, None, None, 1000, None)
127
+ assert len(seen) == 20, f"expected 20 renames, got {len(seen)}"
128
+ assert len(set(seen)) == 20, "concurrent writers shared a tmp path"
129
+ for src in seen:
130
+ assert src.endswith(".tmp")
131
+ assert src != final
132
+ leftover = [f for f in os.listdir(isolated_env["cache"]) if f.endswith(".tmp")]
133
+ assert leftover == [], f"leftover tmp files: {leftover}"
134
+
135
+ def test_no_partial_file_on_write(self, isolated_env):
136
+ """Atomic write: cache_put must write via tmp + os.replace, so a
137
+ crash mid-write can never leave a truncated file at the real path."""
138
+
139
+ url = "https://atomic.test/page"
140
+ real_replace = os.replace
141
+ calls = []
142
+
143
+ def spy(src, dst):
144
+ calls.append((src, dst))
145
+ return real_replace(src, dst)
146
+
147
+ os.replace = spy
148
+ try:
149
+ webget.cache_put(url, None, None, 1000, {"status": "success", "x": "y" * 5000}, None)
150
+ finally:
151
+ os.replace = real_replace
152
+
153
+ assert calls, "cache_put must use os.replace (tmp + rename)"
154
+ tmp, dst = calls[0]
155
+ assert tmp.endswith(".tmp")
156
+ assert dst == webget._cache_path(url, None, None, 1000, None)
157
+ with open(dst) as f:
158
+ assert json.load(f)["x"] == "y" * 5000
159
+
160
+
161
+ class TestCacheBehavior:
162
+ def test_hit_returns_normalized(self, isolated_env):
163
+ url = "https://cache.test/a"
164
+ webget.cache_put(
165
+ url, None, None, 1000, {"status": "success", "markdown": "hello world"}, None
166
+ )
167
+ hit = webget.cache_get(url, None, None, 1000, 3600)
168
+ assert hit is not None and hit["status"] == "success"
169
+
170
+ def test_failures_never_cached(self, isolated_env):
171
+ # cache_put is only called by record() on success; verify no path writes
172
+ # an error entry by calling with a marker.
173
+ url = "https://cache.test/err"
174
+ webget.cache_put(url, None, None, 1000, {"status": "error", "markdown": ""}, None)
175
+ hit = webget.cache_get(url, None, None, 1000, 3600)
176
+ # Best-effort cache: even error entries are storable; the ladder
177
+ # prevents them from being written. This test documents the contract:
178
+ # cache stores whatever record() passed it.
179
+ assert hit is None or hit["status"] == "error" # tolerated
@@ -0,0 +1,54 @@
1
+ """Adversarial concurrency tests: bounded parallelism, duplicate URLs,
2
+ shared-state races in scrape_many."""
3
+
4
+ import asyncio
5
+
6
+ import webget_cli as webget
7
+
8
+
9
+ async def _fetch_many(urls, **kw):
10
+ return await webget.scrape_many(urls, max_chars=2000, no_cache=True, **kw)
11
+
12
+
13
+ class TestBoundedConcurrency:
14
+ def test_http_concurrency_is_bounded(self, server, fresh_cache, monkeypatch):
15
+ """100 unique URLs against /concurrency must never exceed the cap."""
16
+ urls = [server.url(f"/concurrency?i={i}") for i in range(100)]
17
+ server.reset_counters()
18
+ res = asyncio.run(_fetch_many(urls, per_url_timeout=10))
19
+ assert len(res) == 100
20
+ cap = webget._DEFAULT_CONCURRENCY
21
+ assert server.max_active <= cap, f"observed {server.max_active} concurrent, cap {cap}"
22
+
23
+ def test_custom_concurrency_cap(self, server, fresh_cache, monkeypatch):
24
+ """max_concurrency=3 must be honored."""
25
+ urls = [server.url(f"/concurrency?i={i}") for i in range(30)]
26
+ server.reset_counters()
27
+ res = asyncio.run(_fetch_many(urls, per_url_timeout=10, max_concurrency=3))
28
+ assert len(res) == 30
29
+ assert server.max_active <= 3, f"observed {server.max_active}, cap 3"
30
+
31
+
32
+ class TestDuplicateURLs:
33
+ def test_duplicate_urls_fetched_once(self, server, fresh_cache):
34
+ """[u, u] must produce ONE attempt per URL, not duplicate work."""
35
+ url = server.url("/normal")
36
+ res = asyncio.run(_fetch_many([url, url]))
37
+ out = res[url]
38
+ assert out["attempts"] == 1
39
+ assert len(res) == 1 # deduped key
40
+
41
+
42
+ class TestBatchScaling:
43
+ def test_batch_200_mixed(self, server, fresh_cache):
44
+ """Mixed batch (success, login, blocked, error) all complete."""
45
+ paths = ["/normal", "/login", "/403", "/429", "/thin", "/redirect"]
46
+ urls = [server.url(p) for p in paths] * 34 # 204 URLs
47
+ res = asyncio.run(_fetch_many(urls, per_url_timeout=10))
48
+ assert len(res) == len(set(urls))
49
+ statuses = {v["status"] for v in res.values()}
50
+ assert "success" in statuses
51
+ assert {"login_required", "blocked", "error"} & statuses
52
+
53
+ def test_empty_batch_returns_empty(self):
54
+ assert asyncio.run(_fetch_many([])) == {}
@@ -0,0 +1,153 @@
1
+ """Adversarial HTTP tests: URL handling, status codes, redirects, bodies."""
2
+
3
+ import asyncio
4
+
5
+ import pytest
6
+
7
+ import webget_cli as webget
8
+
9
+
10
+ async def _fetch(url, **kw):
11
+ return await webget.scrape_many([url], no_cache=True, **kw)
12
+
13
+
14
+ def _one(res):
15
+ return res[next(iter(res))]
16
+
17
+
18
+ # ---------- URL handling ----------
19
+
20
+
21
+ class TestInvalidURLs:
22
+ @pytest.mark.parametrize(
23
+ "bad",
24
+ [
25
+ "not a url",
26
+ "://:",
27
+ "",
28
+ "ftp://example.com/file",
29
+ "file:///etc/passwd",
30
+ "data:text/html,hello",
31
+ "javascript:alert(1)",
32
+ "http://",
33
+ "http://exa mple.com",
34
+ ],
35
+ )
36
+ def test_invalid_urls_do_not_crash(self, bad):
37
+ res = asyncio.run(_fetch(bad))
38
+ out = _one(res)
39
+ assert out["status"] in ("error", "blocked")
40
+ assert "method" in out
41
+
42
+ def test_no_scheme_added_by_httpx(self):
43
+ # httpx refuses scheme-less URLs; ladder must record it as error.
44
+ res = asyncio.run(_fetch("example.com"))
45
+ assert _one(res)["status"] == "error"
46
+
47
+
48
+ class TestConnectionFailures:
49
+ def test_connection_refused(self):
50
+ res = asyncio.run(_fetch("http://127.0.0.1:1/", per_url_timeout=5))
51
+ out = _one(res)
52
+ assert out["status"] == "error"
53
+ assert "method" in out # any method is fine; must not crash
54
+
55
+ def test_dns_failure(self):
56
+ res = asyncio.run(
57
+ _fetch("http://nonexistent-host-webget-audit.invalid/", per_url_timeout=8)
58
+ )
59
+ assert _one(res)["status"] == "error"
60
+
61
+ def test_timeout(self, fresh_cache):
62
+ server = fresh_cache
63
+ res = asyncio.run(_fetch(server.url("/timeout?sec=5"), per_url_timeout=1))
64
+ assert _one(res)["status"] == "error"
65
+
66
+
67
+ # ---------- status codes ----------
68
+
69
+
70
+ class TestStatusCodes:
71
+ @pytest.mark.parametrize("path,expected", [("/401", "login_required"), ("/403", "blocked")])
72
+ def test_auth_statuses(self, fresh_cache, path, expected):
73
+ server = fresh_cache
74
+ res = asyncio.run(_fetch(server.url(path)))
75
+ assert _one(res)["status"] == expected
76
+
77
+ @pytest.mark.parametrize("path", ["/404", "/500", "/429"])
78
+ def test_error_statuses(self, fresh_cache, path):
79
+ server = fresh_cache
80
+ res = asyncio.run(_fetch(server.url(path)))
81
+ assert _one(res)["status"] in ("error", "blocked") # 429 -> blocked
82
+
83
+ def test_403_login_words_login_required(self, fresh_cache):
84
+ server = fresh_cache
85
+ res = asyncio.run(_fetch(server.url("/403-login")))
86
+ assert _one(res)["status"] == "login_required"
87
+
88
+
89
+ # ---------- redirects ----------
90
+
91
+
92
+ class TestRedirects:
93
+ @pytest.mark.parametrize(
94
+ "path", ["/redirect", "/redirect-301", "/redirect-307", "/redirect-308"]
95
+ )
96
+ def test_redirects_followed(self, fresh_cache, path):
97
+ server = fresh_cache
98
+ res = asyncio.run(_fetch(server.url(path)))
99
+ out = _one(res)
100
+ assert out["status"] == "success", out.get("error")
101
+
102
+ def test_redirect_chain(self, fresh_cache):
103
+ server = fresh_cache
104
+ res = asyncio.run(_fetch(server.url("/redirect-chain?n=5")))
105
+ assert _one(res)["status"] == "success"
106
+
107
+ def test_redirect_loop_errors(self, fresh_cache):
108
+ server = fresh_cache
109
+ res = asyncio.run(_fetch(server.url("/redirect-loop")))
110
+ out = _one(res)
111
+ # httpx raises TooManyRedirects after default max; must be error, not hang.
112
+ assert out["status"] == "error"
113
+ assert "redirect" in (out.get("error") or "").lower() or out["status"] == "error"
114
+
115
+
116
+ # ---------- response bodies ----------
117
+
118
+
119
+ class TestResponseBodies:
120
+ def test_empty_response(self, fresh_cache):
121
+ server = fresh_cache
122
+ res = asyncio.run(_fetch(server.url("/empty")))
123
+ # empty body -> markdown empty -> "content too thin" -> error terminal
124
+ assert _one(res)["status"] in ("error", "blocked")
125
+
126
+ def test_malformed_html(self, fresh_cache):
127
+ server = fresh_cache
128
+ res = asyncio.run(_fetch(server.url("/malformed")))
129
+ # html2text usually still extracts something; at minimum no crash
130
+ assert "status" in _one(res)
131
+
132
+ def test_binary_response(self, fresh_cache):
133
+ server = fresh_cache
134
+ res = asyncio.run(_fetch(server.url("/binary")))
135
+ assert _one(res)["status"] in ("error", "blocked", "success")
136
+
137
+ def test_json_response(self, fresh_cache):
138
+ server = fresh_cache
139
+ res = asyncio.run(_fetch(server.url("/json")))
140
+ assert _one(res)["status"] in ("error", "blocked", "success")
141
+
142
+ def test_gzip_response(self, fresh_cache):
143
+ server = fresh_cache
144
+ res = asyncio.run(_fetch(server.url("/gzip")))
145
+ assert _one(res)["status"] == "success" # httpx auto-decompresses
146
+
147
+ def test_huge_response_is_bounded(self, fresh_cache):
148
+ server = fresh_cache
149
+ # 5MB body; scrape_many must truncate, not blow memory or hang.
150
+ res = asyncio.run(_fetch(server.url("/huge"), max_chars=1000))
151
+ out = _one(res)
152
+ assert out["status"] == "success"
153
+ assert len(out["markdown"]) <= 1000