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.
@@ -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)