bas-http 1.0.0__py3-none-any.whl
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.
- bas/README.md +244 -0
- bas/__init__.py +97 -0
- bas/_curl.py +393 -0
- bas/async_session.py +352 -0
- bas/cookies.py +643 -0
- bas/curl_parser.py +267 -0
- bas/impersonate.py +424 -0
- bas/models.py +339 -0
- bas/session.py +648 -0
- bas/simple_session.py +299 -0
- bas/tests/__init__.py +0 -0
- bas/tests/test_cookies.py +778 -0
- bas/tests/test_curl_parser.py +172 -0
- bas/tests/test_real_request.py +142 -0
- bas/tests/test_spaceshooter.py +213 -0
- bas_http-1.0.0.dist-info/METADATA +25 -0
- bas_http-1.0.0.dist-info/RECORD +19 -0
- bas_http-1.0.0.dist-info/WHEEL +5 -0
- bas_http-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive tests for bas's cookie handling.
|
|
3
|
+
|
|
4
|
+
These tests verify that bas fixes all the cookie bugs in curlcffi:
|
|
5
|
+
1. Cookies accumulate across session requests
|
|
6
|
+
2. Cookies survive redirects
|
|
7
|
+
3. Proper domain matching (RFC 6265)
|
|
8
|
+
4. Proper path matching (RFC 6265)
|
|
9
|
+
5. Cookie expiration
|
|
10
|
+
6. Secure flag enforcement
|
|
11
|
+
7. Set-Cookie header parsing
|
|
12
|
+
8. Thread safety
|
|
13
|
+
9. JSON/Netscape persistence
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from bas.cookies import Cookie, CookieJar, _parse_cookie_time, _guess_cookie_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Cookie Creation Tests ───────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
class TestCookieCreation:
|
|
28
|
+
def test_basic_cookie(self):
|
|
29
|
+
c = Cookie(name="session", value="abc123")
|
|
30
|
+
assert c.name == "session"
|
|
31
|
+
assert c.value == "abc123"
|
|
32
|
+
assert c.path == "/"
|
|
33
|
+
assert c.secure is False
|
|
34
|
+
assert c.http_only is False
|
|
35
|
+
|
|
36
|
+
def test_cookie_with_all_attributes(self):
|
|
37
|
+
c = Cookie(
|
|
38
|
+
name="token",
|
|
39
|
+
value="xyz",
|
|
40
|
+
domain="example.com",
|
|
41
|
+
path="/app",
|
|
42
|
+
expires=9999999999.0,
|
|
43
|
+
max_age=3600,
|
|
44
|
+
secure=True,
|
|
45
|
+
http_only=True,
|
|
46
|
+
same_site="Strict",
|
|
47
|
+
)
|
|
48
|
+
assert c.secure is True
|
|
49
|
+
assert c.http_only is True
|
|
50
|
+
assert c.same_site == "Strict"
|
|
51
|
+
assert c.effective_expires is not None
|
|
52
|
+
|
|
53
|
+
def test_cookie_to_dict_roundtrip(self):
|
|
54
|
+
c = Cookie(
|
|
55
|
+
name="test", value="val",
|
|
56
|
+
domain="example.com", path="/",
|
|
57
|
+
secure=True, http_only=True,
|
|
58
|
+
)
|
|
59
|
+
d = c.to_dict()
|
|
60
|
+
c2 = Cookie.from_dict(d)
|
|
61
|
+
assert c2.name == c.name
|
|
62
|
+
assert c2.value == c.value
|
|
63
|
+
assert c2.secure == c.secure
|
|
64
|
+
assert c2.http_only == c.http_only
|
|
65
|
+
|
|
66
|
+
def test_cookie_to_header(self):
|
|
67
|
+
c = Cookie(name="sid", value="12345")
|
|
68
|
+
assert c.to_header() == "sid=12345"
|
|
69
|
+
|
|
70
|
+
def test_cookie_equality(self):
|
|
71
|
+
c1 = Cookie(name="a", value="1", domain="example.com", path="/")
|
|
72
|
+
c2 = Cookie(name="a", value="2", domain="example.com", path="/")
|
|
73
|
+
c3 = Cookie(name="a", value="1", domain="other.com", path="/")
|
|
74
|
+
assert c1 == c2 # same name, domain, path
|
|
75
|
+
assert c1 != c3 # different domain
|
|
76
|
+
|
|
77
|
+
def test_cookie_repr(self):
|
|
78
|
+
c = Cookie(name="x", value="y")
|
|
79
|
+
r = repr(c)
|
|
80
|
+
assert "x" in r
|
|
81
|
+
assert "y" in r
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Cookie Expiration Tests ─────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
class TestCookieExpiration:
|
|
87
|
+
def test_no_expiry(self):
|
|
88
|
+
c = Cookie(name="a", value="b")
|
|
89
|
+
assert c.is_expired is False
|
|
90
|
+
|
|
91
|
+
def test_expired_max_age(self):
|
|
92
|
+
c = Cookie(name="a", value="b", max_age=0)
|
|
93
|
+
time.sleep(0.01)
|
|
94
|
+
assert c.is_expired is True
|
|
95
|
+
|
|
96
|
+
def test_not_expired_max_age(self):
|
|
97
|
+
c = Cookie(name="a", value="b", max_age=3600)
|
|
98
|
+
assert c.is_expired is False
|
|
99
|
+
|
|
100
|
+
def test_expired_expires(self):
|
|
101
|
+
c = Cookie(name="a", value="b", expires=time.time() - 1)
|
|
102
|
+
assert c.is_expired is True
|
|
103
|
+
|
|
104
|
+
def test_not_expired_expires(self):
|
|
105
|
+
c = Cookie(name="a", value="b", expires=time.time() + 3600)
|
|
106
|
+
assert c.is_expired is False
|
|
107
|
+
|
|
108
|
+
def test_max_age_overrides_expires(self):
|
|
109
|
+
c = Cookie(
|
|
110
|
+
name="a", value="b",
|
|
111
|
+
expires=time.time() + 3600,
|
|
112
|
+
max_age=0,
|
|
113
|
+
)
|
|
114
|
+
time.sleep(0.01)
|
|
115
|
+
assert c.effective_expires is not None
|
|
116
|
+
assert c.is_expired is True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── Domain Matching Tests (RFC 6265 §5.1.3) ─────────────────────────────────
|
|
120
|
+
|
|
121
|
+
class TestDomainMatching:
|
|
122
|
+
def test_exact_match(self):
|
|
123
|
+
c = Cookie(name="a", value="b", domain="example.com")
|
|
124
|
+
assert c.matches_domain("example.com") is True
|
|
125
|
+
|
|
126
|
+
def test_suffix_match_with_dot(self):
|
|
127
|
+
c = Cookie(name="a", value="b", domain=".example.com")
|
|
128
|
+
assert c.matches_domain("www.example.com") is True
|
|
129
|
+
|
|
130
|
+
def test_suffix_match_subdomain(self):
|
|
131
|
+
c = Cookie(name="a", value="b", domain=".example.com")
|
|
132
|
+
assert c.matches_domain("sub.example.com") is True
|
|
133
|
+
|
|
134
|
+
def test_suffix_no_leading_dot(self):
|
|
135
|
+
"""Without leading dot, must still match as suffix."""
|
|
136
|
+
c = Cookie(name="a", value="b", domain="example.com")
|
|
137
|
+
assert c.matches_domain("www.example.com") is True
|
|
138
|
+
|
|
139
|
+
def test_no_suffix_match_evil(self):
|
|
140
|
+
"""evil.example.com should NOT match example.com."""
|
|
141
|
+
c = Cookie(name="a", value="b", domain="example.com")
|
|
142
|
+
# Without initial dot, only exact match or dot-separated suffix
|
|
143
|
+
assert c.matches_domain("evil.example.com") is True # RFC says this matches
|
|
144
|
+
|
|
145
|
+
def test_no_match(self):
|
|
146
|
+
c = Cookie(name="a", value="b", domain="example.com")
|
|
147
|
+
assert c.matches_domain("other.com") is False
|
|
148
|
+
|
|
149
|
+
def test_empty_domain(self):
|
|
150
|
+
c = Cookie(name="a", value="b", domain="")
|
|
151
|
+
assert c.matches_domain("example.com") is False
|
|
152
|
+
|
|
153
|
+
def test_case_insensitive(self):
|
|
154
|
+
c = Cookie(name="a", value="b", domain="Example.COM")
|
|
155
|
+
assert c.matches_domain("example.com") is True
|
|
156
|
+
|
|
157
|
+
def test_trailing_dot(self):
|
|
158
|
+
c = Cookie(name="a", value="b", domain="example.com.")
|
|
159
|
+
assert c.matches_domain("example.com") is True
|
|
160
|
+
|
|
161
|
+
def test_ip_address_match(self):
|
|
162
|
+
c = Cookie(name="a", value="b", domain="127.0.0.1")
|
|
163
|
+
assert c.matches_domain("127.0.0.1") is True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Path Matching Tests (RFC 6265 §5.1.4) ───────────────────────────────────
|
|
167
|
+
|
|
168
|
+
class TestPathMatching:
|
|
169
|
+
def test_exact_match(self):
|
|
170
|
+
c = Cookie(name="a", value="b", path="/app")
|
|
171
|
+
assert c.matches_path("/app") is True
|
|
172
|
+
|
|
173
|
+
def test_root_matches_all(self):
|
|
174
|
+
c = Cookie(name="a", value="b", path="/")
|
|
175
|
+
assert c.matches_path("/anything") is True
|
|
176
|
+
|
|
177
|
+
def test_prefix_with_slash(self):
|
|
178
|
+
c = Cookie(name="a", value="b", path="/app/")
|
|
179
|
+
assert c.matches_path("/app/page") is True
|
|
180
|
+
|
|
181
|
+
def test_prefix_without_slash(self):
|
|
182
|
+
"""Without trailing slash, must match / or end of string."""
|
|
183
|
+
c = Cookie(name="a", value="b", path="/app")
|
|
184
|
+
assert c.matches_path("/app/page") is True
|
|
185
|
+
assert c.matches_path("/app") is True
|
|
186
|
+
|
|
187
|
+
def test_no_match(self):
|
|
188
|
+
c = Cookie(name="a", value="b", path="/other")
|
|
189
|
+
assert c.matches_path("/app") is False
|
|
190
|
+
|
|
191
|
+
def test_no_match_partial(self):
|
|
192
|
+
"""'/appx' should NOT match '/app'."""
|
|
193
|
+
c = Cookie(name="a", value="b", path="/app")
|
|
194
|
+
assert c.matches_path("/appx") is False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── CookieJar Tests ──────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
class TestCookieJar:
|
|
200
|
+
def test_add_and_get(self):
|
|
201
|
+
jar = CookieJar()
|
|
202
|
+
jar.add(Cookie(name="a", value="1", domain="example.com"))
|
|
203
|
+
c = jar.get("a", domain="example.com")
|
|
204
|
+
assert c is not None
|
|
205
|
+
assert c.value == "1"
|
|
206
|
+
|
|
207
|
+
def test_add_replaces_existing(self):
|
|
208
|
+
jar = CookieJar()
|
|
209
|
+
jar.add(Cookie(name="a", value="1", domain="example.com"))
|
|
210
|
+
jar.add(Cookie(name="a", value="2", domain="example.com"))
|
|
211
|
+
c = jar.get("a", domain="example.com")
|
|
212
|
+
assert c.value == "2"
|
|
213
|
+
assert len(jar) == 1
|
|
214
|
+
|
|
215
|
+
def test_remove(self):
|
|
216
|
+
jar = CookieJar()
|
|
217
|
+
c = Cookie(name="a", value="1", domain="example.com")
|
|
218
|
+
jar.add(c)
|
|
219
|
+
jar.remove(c)
|
|
220
|
+
assert len(jar) == 0
|
|
221
|
+
|
|
222
|
+
def test_clear(self):
|
|
223
|
+
jar = CookieJar()
|
|
224
|
+
jar.add(Cookie(name="a", value="1", domain="example.com"))
|
|
225
|
+
jar.add(Cookie(name="b", value="2", domain="example.com"))
|
|
226
|
+
jar.clear()
|
|
227
|
+
assert len(jar) == 0
|
|
228
|
+
|
|
229
|
+
def test_match_filters_by_domain(self):
|
|
230
|
+
jar = CookieJar()
|
|
231
|
+
jar.add(Cookie(name="a", value="1", domain="example.com"))
|
|
232
|
+
jar.add(Cookie(name="b", value="2", domain="other.com"))
|
|
233
|
+
matched = jar.match("https://example.com/page")
|
|
234
|
+
assert len(matched) == 1
|
|
235
|
+
assert matched[0].name == "a"
|
|
236
|
+
|
|
237
|
+
def test_match_filters_by_path(self):
|
|
238
|
+
jar = CookieJar()
|
|
239
|
+
jar.add(Cookie(name="a", value="1", domain="example.com", path="/app"))
|
|
240
|
+
jar.add(Cookie(name="b", value="2", domain="example.com", path="/other"))
|
|
241
|
+
matched = jar.match("https://example.com/app/page")
|
|
242
|
+
assert len(matched) == 1
|
|
243
|
+
assert matched[0].name == "a"
|
|
244
|
+
|
|
245
|
+
def test_match_excludes_secure_on_http(self):
|
|
246
|
+
jar = CookieJar()
|
|
247
|
+
jar.add(Cookie(name="a", value="1", domain="example.com", secure=True))
|
|
248
|
+
matched = jar.match("http://example.com/")
|
|
249
|
+
assert len(matched) == 0
|
|
250
|
+
|
|
251
|
+
def test_match_includes_secure_on_https(self):
|
|
252
|
+
jar = CookieJar()
|
|
253
|
+
jar.add(Cookie(name="a", value="1", domain="example.com", secure=True))
|
|
254
|
+
matched = jar.match("https://example.com/")
|
|
255
|
+
assert len(matched) == 1
|
|
256
|
+
|
|
257
|
+
def test_match_excludes_expired(self):
|
|
258
|
+
jar = CookieJar()
|
|
259
|
+
jar.add(Cookie(name="a", value="1", domain="example.com", max_age=0))
|
|
260
|
+
time.sleep(0.01)
|
|
261
|
+
matched = jar.match("https://example.com/")
|
|
262
|
+
assert len(matched) == 0
|
|
263
|
+
|
|
264
|
+
def test_to_header(self):
|
|
265
|
+
jar = CookieJar()
|
|
266
|
+
jar.add(Cookie(name="a", value="1", domain="example.com"))
|
|
267
|
+
jar.add(Cookie(name="b", value="2", domain="example.com"))
|
|
268
|
+
header = jar.to_header("https://example.com/")
|
|
269
|
+
assert "a=1" in header
|
|
270
|
+
assert "b=2" in header
|
|
271
|
+
assert "; " in header
|
|
272
|
+
|
|
273
|
+
def test_to_header_empty(self):
|
|
274
|
+
jar = CookieJar()
|
|
275
|
+
assert jar.to_header("https://example.com/") == ""
|
|
276
|
+
|
|
277
|
+
def test_to_header_dict(self):
|
|
278
|
+
jar = CookieJar()
|
|
279
|
+
jar.add(Cookie(name="a", value="1", domain="example.com"))
|
|
280
|
+
d = jar.to_header_dict("https://example.com/")
|
|
281
|
+
assert "Cookie" in d
|
|
282
|
+
assert "a=1" in d["Cookie"]
|
|
283
|
+
|
|
284
|
+
def test_update(self):
|
|
285
|
+
jar1 = CookieJar()
|
|
286
|
+
jar1.add(Cookie(name="a", value="1", domain="example.com"))
|
|
287
|
+
jar2 = CookieJar()
|
|
288
|
+
jar2.add(Cookie(name="b", value="2", domain="example.com"))
|
|
289
|
+
jar1.update(jar2)
|
|
290
|
+
assert len(jar1) == 2
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── Set-Cookie Parsing Tests ────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
class TestSetCookieParsing:
|
|
296
|
+
def test_basic_set_cookie(self):
|
|
297
|
+
jar = CookieJar()
|
|
298
|
+
cookie = jar.parse_set_cookie(
|
|
299
|
+
"session=abc123; Path=/; HttpOnly",
|
|
300
|
+
"https://example.com/login",
|
|
301
|
+
)
|
|
302
|
+
assert cookie is not None
|
|
303
|
+
assert cookie.name == "session"
|
|
304
|
+
assert cookie.value == "abc123"
|
|
305
|
+
assert cookie.http_only is True
|
|
306
|
+
assert cookie.path == "/"
|
|
307
|
+
|
|
308
|
+
def test_set_cookie_with_domain(self):
|
|
309
|
+
jar = CookieJar()
|
|
310
|
+
cookie = jar.parse_set_cookie(
|
|
311
|
+
"token=xyz; Domain=.example.com; Path=/app; Secure",
|
|
312
|
+
"https://www.example.com/page",
|
|
313
|
+
)
|
|
314
|
+
assert cookie is not None
|
|
315
|
+
assert cookie.domain == "example.com"
|
|
316
|
+
assert cookie.secure is True
|
|
317
|
+
assert cookie.path == "/app"
|
|
318
|
+
|
|
319
|
+
def test_set_cookie_with_max_age(self):
|
|
320
|
+
jar = CookieJar()
|
|
321
|
+
cookie = jar.parse_set_cookie(
|
|
322
|
+
"sid=123; Max-Age=3600; Path=/",
|
|
323
|
+
"https://example.com/",
|
|
324
|
+
)
|
|
325
|
+
assert cookie is not None
|
|
326
|
+
assert cookie.max_age == 3600
|
|
327
|
+
assert cookie.is_expired is False
|
|
328
|
+
|
|
329
|
+
def test_set_cookie_with_expires(self):
|
|
330
|
+
jar = CookieJar()
|
|
331
|
+
cookie = jar.parse_set_cookie(
|
|
332
|
+
"sid=123; Expires=Thu, 01 Jan 2030 00:00:00 GMT; Path=/",
|
|
333
|
+
"https://example.com/",
|
|
334
|
+
)
|
|
335
|
+
assert cookie is not None
|
|
336
|
+
assert cookie.expires is not None
|
|
337
|
+
assert cookie.expires > time.time()
|
|
338
|
+
|
|
339
|
+
def test_set_cookie_with_samesite(self):
|
|
340
|
+
jar = CookieJar()
|
|
341
|
+
cookie = jar.parse_set_cookie(
|
|
342
|
+
"csrf=abc; SameSite=Strict; Path=/",
|
|
343
|
+
"https://example.com/",
|
|
344
|
+
)
|
|
345
|
+
assert cookie is not None
|
|
346
|
+
assert cookie.same_site == "Strict"
|
|
347
|
+
|
|
348
|
+
def test_delete_cookie(self):
|
|
349
|
+
"""When expires is in the past, cookie should be expired."""
|
|
350
|
+
jar = CookieJar()
|
|
351
|
+
cookie = jar.parse_set_cookie(
|
|
352
|
+
"sid=123; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/",
|
|
353
|
+
"https://example.com/",
|
|
354
|
+
)
|
|
355
|
+
assert cookie is not None
|
|
356
|
+
assert cookie.is_expired is True
|
|
357
|
+
|
|
358
|
+
def test_parse_multiple_set_cookie(self):
|
|
359
|
+
jar = CookieJar()
|
|
360
|
+
headers = {
|
|
361
|
+
"Set-Cookie": [
|
|
362
|
+
"a=1; Path=/",
|
|
363
|
+
"b=2; Path=/app",
|
|
364
|
+
"c=3; Path=/other",
|
|
365
|
+
]
|
|
366
|
+
}
|
|
367
|
+
cookies = jar.parse_set_cookie_headers(headers, "https://example.com/page")
|
|
368
|
+
assert len(cookies) == 3
|
|
369
|
+
assert len(jar) == 3
|
|
370
|
+
|
|
371
|
+
def test_set_cookie_default_path(self):
|
|
372
|
+
"""Default path should be guessed from request path."""
|
|
373
|
+
jar = CookieJar()
|
|
374
|
+
cookie = jar.parse_set_cookie(
|
|
375
|
+
"a=1",
|
|
376
|
+
"https://example.com/app/page",
|
|
377
|
+
)
|
|
378
|
+
assert cookie is not None
|
|
379
|
+
# Should guess /app as the default path
|
|
380
|
+
assert cookie.path == "/app"
|
|
381
|
+
|
|
382
|
+
def test_set_cookie_root_default_path(self):
|
|
383
|
+
jar = CookieJar()
|
|
384
|
+
cookie = jar.parse_set_cookie(
|
|
385
|
+
"a=1",
|
|
386
|
+
"https://example.com/",
|
|
387
|
+
)
|
|
388
|
+
assert cookie is not None
|
|
389
|
+
assert cookie.path == "/"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ── Cookie Persistence Tests ────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
class TestCookiePersistence:
|
|
395
|
+
def test_json_save_load(self):
|
|
396
|
+
jar = CookieJar()
|
|
397
|
+
jar.add(Cookie(
|
|
398
|
+
name="session", value="abc123",
|
|
399
|
+
domain="example.com", path="/app",
|
|
400
|
+
secure=True, http_only=True,
|
|
401
|
+
))
|
|
402
|
+
jar.add(Cookie(
|
|
403
|
+
name="csrf", value="xyz",
|
|
404
|
+
domain="example.com", path="/",
|
|
405
|
+
))
|
|
406
|
+
|
|
407
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
408
|
+
filepath = f.name
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
jar.save_json(filepath)
|
|
412
|
+
|
|
413
|
+
jar2 = CookieJar()
|
|
414
|
+
jar2.load_json(filepath)
|
|
415
|
+
|
|
416
|
+
assert len(jar2) == 2
|
|
417
|
+
c = jar2.get("session", domain="example.com", path="/app")
|
|
418
|
+
assert c is not None
|
|
419
|
+
assert c.value == "abc123"
|
|
420
|
+
assert c.secure is True
|
|
421
|
+
assert c.http_only is True
|
|
422
|
+
finally:
|
|
423
|
+
os.unlink(filepath)
|
|
424
|
+
|
|
425
|
+
def test_netscape_save_load(self):
|
|
426
|
+
jar = CookieJar()
|
|
427
|
+
jar.add(Cookie(
|
|
428
|
+
name="sid", value="xyz",
|
|
429
|
+
domain="example.com", path="/",
|
|
430
|
+
secure=True,
|
|
431
|
+
domain_initial_dot=True,
|
|
432
|
+
))
|
|
433
|
+
|
|
434
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
|
435
|
+
filepath = f.name
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
jar.save_netscape(filepath)
|
|
439
|
+
|
|
440
|
+
jar2 = CookieJar()
|
|
441
|
+
jar2.load_netscape(filepath)
|
|
442
|
+
|
|
443
|
+
assert len(jar2) == 1
|
|
444
|
+
c = jar2.get("sid", domain="example.com")
|
|
445
|
+
assert c is not None
|
|
446
|
+
assert c.value == "xyz"
|
|
447
|
+
finally:
|
|
448
|
+
os.unlink(filepath)
|
|
449
|
+
|
|
450
|
+
def test_load_nonexistent_file(self):
|
|
451
|
+
jar = CookieJar()
|
|
452
|
+
jar.load_json("/nonexistent/path.json")
|
|
453
|
+
assert len(jar) == 0
|
|
454
|
+
|
|
455
|
+
def test_json_preserves_attributes(self):
|
|
456
|
+
jar = CookieJar()
|
|
457
|
+
jar.add(Cookie(
|
|
458
|
+
name="a", value="b",
|
|
459
|
+
domain="example.com", path="/app",
|
|
460
|
+
secure=True, http_only=True,
|
|
461
|
+
same_site="Lax",
|
|
462
|
+
max_age=3600,
|
|
463
|
+
))
|
|
464
|
+
|
|
465
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
466
|
+
filepath = f.name
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
jar.save_json(filepath)
|
|
470
|
+
jar2 = CookieJar()
|
|
471
|
+
jar2.load_json(filepath)
|
|
472
|
+
c = jar2.get("a")
|
|
473
|
+
assert c.secure is True
|
|
474
|
+
assert c.http_only is True
|
|
475
|
+
assert c.same_site == "Lax"
|
|
476
|
+
assert c.max_age == 3600
|
|
477
|
+
finally:
|
|
478
|
+
os.unlink(filepath)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ── Thread Safety Tests ─────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
class TestThreadSafety:
|
|
484
|
+
def test_concurrent_add(self):
|
|
485
|
+
jar = CookieJar()
|
|
486
|
+
errors = []
|
|
487
|
+
|
|
488
|
+
def add_cookies(prefix, count):
|
|
489
|
+
try:
|
|
490
|
+
for i in range(count):
|
|
491
|
+
jar.add(Cookie(
|
|
492
|
+
name=f"{prefix}_{i}",
|
|
493
|
+
value=str(i),
|
|
494
|
+
domain="example.com",
|
|
495
|
+
))
|
|
496
|
+
except Exception as e:
|
|
497
|
+
errors.append(e)
|
|
498
|
+
|
|
499
|
+
threads = [
|
|
500
|
+
threading.Thread(target=add_cookies, args=(f"t{t}", 100))
|
|
501
|
+
for t in range(10)
|
|
502
|
+
]
|
|
503
|
+
for t in threads:
|
|
504
|
+
t.start()
|
|
505
|
+
for t in threads:
|
|
506
|
+
t.join()
|
|
507
|
+
|
|
508
|
+
assert len(errors) == 0
|
|
509
|
+
assert len(jar) == 1000 # 10 threads * 100 cookies
|
|
510
|
+
|
|
511
|
+
def test_concurrent_match(self):
|
|
512
|
+
jar = CookieJar()
|
|
513
|
+
for i in range(100):
|
|
514
|
+
jar.add(Cookie(
|
|
515
|
+
name=f"c{i}", value=str(i),
|
|
516
|
+
domain="example.com", path="/",
|
|
517
|
+
))
|
|
518
|
+
|
|
519
|
+
errors = []
|
|
520
|
+
|
|
521
|
+
def match_cookies():
|
|
522
|
+
try:
|
|
523
|
+
for _ in range(100):
|
|
524
|
+
jar.match("https://example.com/page")
|
|
525
|
+
except Exception as e:
|
|
526
|
+
errors.append(e)
|
|
527
|
+
|
|
528
|
+
threads = [threading.Thread(target=match_cookies) for _ in range(10)]
|
|
529
|
+
for t in threads:
|
|
530
|
+
t.start()
|
|
531
|
+
for t in threads:
|
|
532
|
+
t.join()
|
|
533
|
+
|
|
534
|
+
assert len(errors) == 0
|
|
535
|
+
|
|
536
|
+
def test_concurrent_mixed(self):
|
|
537
|
+
jar = CookieJar()
|
|
538
|
+
errors = []
|
|
539
|
+
|
|
540
|
+
def writer():
|
|
541
|
+
try:
|
|
542
|
+
for i in range(50):
|
|
543
|
+
jar.add(Cookie(name=f"w{i}", value=str(i), domain="example.com"))
|
|
544
|
+
except Exception as e:
|
|
545
|
+
errors.append(e)
|
|
546
|
+
|
|
547
|
+
def reader():
|
|
548
|
+
try:
|
|
549
|
+
for _ in range(50):
|
|
550
|
+
jar.match("https://example.com/")
|
|
551
|
+
except Exception as e:
|
|
552
|
+
errors.append(e)
|
|
553
|
+
|
|
554
|
+
def clearer():
|
|
555
|
+
try:
|
|
556
|
+
for _ in range(5):
|
|
557
|
+
jar.clear()
|
|
558
|
+
except Exception as e:
|
|
559
|
+
errors.append(e)
|
|
560
|
+
|
|
561
|
+
threads = [
|
|
562
|
+
threading.Thread(target=writer),
|
|
563
|
+
threading.Thread(target=reader),
|
|
564
|
+
threading.Thread(target=clearer),
|
|
565
|
+
]
|
|
566
|
+
for t in threads:
|
|
567
|
+
t.start()
|
|
568
|
+
for t in threads:
|
|
569
|
+
t.join()
|
|
570
|
+
|
|
571
|
+
assert len(errors) == 0
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ── URL Resolution Tests ────────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
class TestUrlResolution:
|
|
577
|
+
def test_default_path_guessing(self):
|
|
578
|
+
assert _guess_cookie_path("/app/page") == "/app"
|
|
579
|
+
assert _guess_cookie_path("/") == "/"
|
|
580
|
+
assert _guess_cookie_path("/a/b/c") == "/a/b"
|
|
581
|
+
assert _guess_cookie_path("") == "/"
|
|
582
|
+
assert _guess_cookie_path("relative") == "/"
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ── Cookie Time Parsing Tests ──────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
class TestCookieTimeParsing:
|
|
588
|
+
def test_rfc1123(self):
|
|
589
|
+
t = _parse_cookie_time("Thu, 01 Dec 2030 16:00:00 GMT")
|
|
590
|
+
assert t is not None
|
|
591
|
+
assert t > time.time()
|
|
592
|
+
|
|
593
|
+
def test_rfc1036(self):
|
|
594
|
+
t = _parse_cookie_time("Thursday, 01-Dec-30 16:00:00 GMT")
|
|
595
|
+
assert t is not None
|
|
596
|
+
|
|
597
|
+
def test_invalid_format(self):
|
|
598
|
+
t = _parse_cookie_time("not-a-date")
|
|
599
|
+
assert t is None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ── curlcffi Bug Regression Tests ───────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
class TestCurlCffiRegressions:
|
|
605
|
+
"""
|
|
606
|
+
Tests specifically targeting the bugs reported in curlcffi.
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
def test_cookie_accumulation_across_requests(self):
|
|
610
|
+
"""
|
|
611
|
+
Regression: curlcffi issue #582
|
|
612
|
+
Cookies from previous requests were not available in the session.
|
|
613
|
+
|
|
614
|
+
In bas, cookies accumulate in our Python CookieJar,
|
|
615
|
+
independent of curl's internal state.
|
|
616
|
+
"""
|
|
617
|
+
jar = CookieJar()
|
|
618
|
+
|
|
619
|
+
# Simulate response 1: server sets session cookie
|
|
620
|
+
jar.parse_set_cookie(
|
|
621
|
+
"session-id=141-7615304-0276820; Path=/",
|
|
622
|
+
"https://www.amazon.com/",
|
|
623
|
+
)
|
|
624
|
+
jar.parse_set_cookie(
|
|
625
|
+
"session-id-time=2082787201l; Path=/",
|
|
626
|
+
"https://www.amazon.com/",
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# Verify cookies are in the jar
|
|
630
|
+
header1 = jar.to_header("https://www.amazon.com/")
|
|
631
|
+
assert "session-id=141-7615304-0276820" in header1
|
|
632
|
+
assert "session-id-time=2082787201l" in header1
|
|
633
|
+
|
|
634
|
+
# Simulate response 2: server sets more cookies
|
|
635
|
+
jar.parse_set_cookie(
|
|
636
|
+
"ubid-main=131-0018707-6925852; Path=/",
|
|
637
|
+
"https://www.amazon.com/",
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# ALL cookies should be present now
|
|
641
|
+
header2 = jar.to_header("https://www.amazon.com/")
|
|
642
|
+
assert "session-id=141-7615304-0276820" in header2
|
|
643
|
+
assert "session-id-time=2082787201l" in header2
|
|
644
|
+
assert "ubid-main=131-0018707-6925852" in header2
|
|
645
|
+
|
|
646
|
+
# Simulate response 3: even more cookies
|
|
647
|
+
jar.parse_set_cookie(
|
|
648
|
+
"session-token=+6MN/6QJEjQ3Q; Path=/",
|
|
649
|
+
"https://www.amazon.com/",
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
header3 = jar.to_header("https://www.amazon.com/")
|
|
653
|
+
assert "session-id=141-7615304-0276820" in header3
|
|
654
|
+
assert "ubid-main=131-0018707-6925852" in header3
|
|
655
|
+
assert "session-token=+6MN/6QJEjQ3Q" in header3
|
|
656
|
+
|
|
657
|
+
def test_redirect_cookie_inheritance(self):
|
|
658
|
+
"""
|
|
659
|
+
Regression: curlcffi issue #55
|
|
660
|
+
Cookies were lost during 302 redirects.
|
|
661
|
+
|
|
662
|
+
In bas, the cookie jar persists across redirects.
|
|
663
|
+
"""
|
|
664
|
+
jar = CookieJar()
|
|
665
|
+
|
|
666
|
+
# Request 1 to origin server
|
|
667
|
+
jar.parse_set_cookie(
|
|
668
|
+
"tracking=abc123; Domain=.example.com; Path=/",
|
|
669
|
+
"https://www.example.com/page",
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Verify cookie exists
|
|
673
|
+
header = jar.to_header("https://www.example.com/other")
|
|
674
|
+
assert "tracking=abc123" in header
|
|
675
|
+
|
|
676
|
+
# After redirect, cookie should still be there
|
|
677
|
+
jar.parse_set_cookie(
|
|
678
|
+
"redirect_id=xyz; Path=/",
|
|
679
|
+
"https://www.example.com/redirected",
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
header2 = jar.to_header("https://www.example.com/final")
|
|
683
|
+
assert "tracking=abc123" in header2
|
|
684
|
+
assert "redirect_id=xyz" in header2
|
|
685
|
+
|
|
686
|
+
def test_cross_domain_redirect_cookies(self):
|
|
687
|
+
"""
|
|
688
|
+
Test that cookies are properly handled during cross-domain redirects.
|
|
689
|
+
"""
|
|
690
|
+
jar = CookieJar()
|
|
691
|
+
|
|
692
|
+
# Server A sets a cookie
|
|
693
|
+
jar.parse_set_cookie(
|
|
694
|
+
"from_a=value1; Domain=.a.com; Path=/",
|
|
695
|
+
"https://a.com/page",
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# Server B sets a cookie
|
|
699
|
+
jar.parse_set_cookie(
|
|
700
|
+
"from_b=value2; Domain=.b.com; Path=/",
|
|
701
|
+
"https://b.com/page",
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# Each domain should only see its own cookies
|
|
705
|
+
header_a = jar.to_header("https://a.com/page")
|
|
706
|
+
assert "from_a=value1" in header_a
|
|
707
|
+
assert "from_b=value2" not in header_a
|
|
708
|
+
|
|
709
|
+
header_b = jar.to_header("https://b.com/page")
|
|
710
|
+
assert "from_b=value2" in header_b
|
|
711
|
+
assert "from_a=value1" not in header_b
|
|
712
|
+
|
|
713
|
+
def test_no_manual_cookie_header_needed(self):
|
|
714
|
+
"""
|
|
715
|
+
One of curlcffi's annoyances: users had to manually build
|
|
716
|
+
Cookie headers. bas handles this automatically.
|
|
717
|
+
"""
|
|
718
|
+
jar = CookieJar()
|
|
719
|
+
jar.parse_set_cookie("a=1; Path=/", "https://example.com/")
|
|
720
|
+
jar.parse_set_cookie("b=2; Path=/", "https://example.com/")
|
|
721
|
+
|
|
722
|
+
# to_header builds the complete header automatically
|
|
723
|
+
header = jar.to_header("https://example.com/")
|
|
724
|
+
assert header == "a=1; b=2"
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
# ── Run tests ────────────────────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
def run_all_tests():
|
|
730
|
+
"""Simple test runner."""
|
|
731
|
+
test_classes = [
|
|
732
|
+
TestCookieCreation,
|
|
733
|
+
TestCookieExpiration,
|
|
734
|
+
TestDomainMatching,
|
|
735
|
+
TestPathMatching,
|
|
736
|
+
TestCookieJar,
|
|
737
|
+
TestSetCookieParsing,
|
|
738
|
+
TestCookiePersistence,
|
|
739
|
+
TestThreadSafety,
|
|
740
|
+
TestUrlResolution,
|
|
741
|
+
TestCookieTimeParsing,
|
|
742
|
+
TestCurlCffiRegressions,
|
|
743
|
+
]
|
|
744
|
+
|
|
745
|
+
total = 0
|
|
746
|
+
passed = 0
|
|
747
|
+
failed = 0
|
|
748
|
+
errors = []
|
|
749
|
+
|
|
750
|
+
for cls in test_classes:
|
|
751
|
+
instance = cls()
|
|
752
|
+
methods = [m for m in dir(instance) if m.startswith("test_")]
|
|
753
|
+
for method_name in sorted(methods):
|
|
754
|
+
total += 1
|
|
755
|
+
method = getattr(instance, method_name)
|
|
756
|
+
try:
|
|
757
|
+
method()
|
|
758
|
+
passed += 1
|
|
759
|
+
print(f" PASS {cls.__name__}.{method_name}")
|
|
760
|
+
except Exception as e:
|
|
761
|
+
failed += 1
|
|
762
|
+
errors.append((cls.__name__, method_name, str(e)))
|
|
763
|
+
print(f" FAIL {cls.__name__}.{method_name}: {e}")
|
|
764
|
+
|
|
765
|
+
print(f"\n{'='*60}")
|
|
766
|
+
print(f"Results: {passed} passed, {failed} failed, {total} total")
|
|
767
|
+
if errors:
|
|
768
|
+
print(f"\nFailures:")
|
|
769
|
+
for cls_name, method, err in errors:
|
|
770
|
+
print(f" {cls_name}.{method}: {err}")
|
|
771
|
+
print(f"{'='*60}")
|
|
772
|
+
|
|
773
|
+
return failed == 0
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
if __name__ == "__main__":
|
|
777
|
+
success = run_all_tests()
|
|
778
|
+
exit(0 if success else 1)
|