···
1
+
(*---------------------------------------------------------------------------
2
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3
+
SPDX-License-Identifier: ISC
4
+
---------------------------------------------------------------------------*)
9
+
(* Testable helpers for Priority 2 types *)
10
+
let expiration_testable : Cookeio.Expiration.t Alcotest.testable =
11
+
Alcotest.testable Cookeio.Expiration.pp Cookeio.Expiration.equal
13
+
let span_testable : Ptime.Span.t Alcotest.testable =
14
+
Alcotest.testable Ptime.Span.pp Ptime.Span.equal
16
+
let same_site_testable : Cookeio.SameSite.t Alcotest.testable =
17
+
Alcotest.testable Cookeio.SameSite.pp Cookeio.SameSite.equal
let cookie_testable : Cookeio.t Alcotest.testable =
"{ name=%S; value=%S; domain=%S; path=%S; secure=%b; http_only=%b; \
8
-
expires=%a; same_site=%a }"
9
-
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c) (Cookeio.secure c) (Cookeio.http_only c)
10
-
(Format.pp_print_option Ptime.pp)
24
+
partitioned=%b; expires=%a; max_age=%a; same_site=%a }"
25
+
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c)
26
+
(Cookeio.secure c) (Cookeio.http_only c) (Cookeio.partitioned c)
27
+
(Format.pp_print_option (fun ppf e ->
29
+
| `Session -> Format.pp_print_string ppf "Session"
30
+
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t))
32
+
(Format.pp_print_option Ptime.Span.pp)
(Format.pp_print_option (fun ppf -> function
| `Strict -> Format.pp_print_string ppf "Strict"
| `Lax -> Format.pp_print_string ppf "Lax"
| `None -> Format.pp_print_string ppf "None"))
18
-
Cookeio.name c1 = Cookeio.name c2 && Cookeio.value c1 = Cookeio.value c2 && Cookeio.domain c1 = Cookeio.domain c2
19
-
&& Cookeio.path c1 = Cookeio.path c2 && Cookeio.secure c1 = Cookeio.secure c2
40
+
let expires_equal e1 e2 =
42
+
| None, None -> true
43
+
| Some `Session, Some `Session -> true
44
+
| Some (`DateTime t1), Some (`DateTime t2) -> Ptime.equal t1 t2
47
+
Cookeio.name c1 = Cookeio.name c2
48
+
&& Cookeio.value c1 = Cookeio.value c2
49
+
&& Cookeio.domain c1 = Cookeio.domain c2
50
+
&& Cookeio.path c1 = Cookeio.path c2
51
+
&& Cookeio.secure c1 = Cookeio.secure c2
&& Cookeio.http_only c1 = Cookeio.http_only c2
21
-
&& Option.equal Ptime.equal (Cookeio.expires c1) (Cookeio.expires c2)
53
+
&& Cookeio.partitioned c1 = Cookeio.partitioned c2
54
+
&& expires_equal (Cookeio.expires c1) (Cookeio.expires c2)
55
+
&& Option.equal Ptime.Span.equal (Cookeio.max_age c1) (Cookeio.max_age c2)
&& Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
let test_load_mozilla_cookies env =
···
(* Test cookie-1: session cookie on exact domain *)
let cookie1 = find_cookie "cookie-1" in
52
-
Alcotest.(check string) "cookie-1 domain" "example.com" (Cookeio.domain cookie1);
86
+
Alcotest.(check string)
87
+
"cookie-1 domain" "example.com" (Cookeio.domain cookie1);
Alcotest.(check string) "cookie-1 path" "/foo/" (Cookeio.path cookie1);
Alcotest.(check string) "cookie-1 name" "cookie-1" (Cookeio.name cookie1);
Alcotest.(check string) "cookie-1 value" "v$1" (Cookeio.value cookie1);
Alcotest.(check bool) "cookie-1 secure" false (Cookeio.secure cookie1);
Alcotest.(check bool) "cookie-1 http_only" false (Cookeio.http_only cookie1);
58
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
93
+
Alcotest.(check (option expiration_testable))
"cookie-1 expires" None (Cookeio.expires cookie1);
···
| `Lax -> Format.pp_print_string ppf "Lax"
| `None -> Format.pp_print_string ppf "None")
69
-
"cookie-1 same_site" None (Cookeio.same_site cookie1);
104
+
"cookie-1 same_site" None
105
+
(Cookeio.same_site cookie1);
(* Test cookie-2: session cookie on subdomain pattern *)
let cookie2 = find_cookie "cookie-2" in
73
-
Alcotest.(check string) "cookie-2 domain" ".example.com" (Cookeio.domain cookie2);
109
+
Alcotest.(check string)
110
+
"cookie-2 domain" "example.com" (Cookeio.domain cookie2);
Alcotest.(check string) "cookie-2 path" "/foo/" (Cookeio.path cookie2);
Alcotest.(check string) "cookie-2 name" "cookie-2" (Cookeio.name cookie2);
Alcotest.(check string) "cookie-2 value" "v$2" (Cookeio.value cookie2);
Alcotest.(check bool) "cookie-2 secure" false (Cookeio.secure cookie2);
Alcotest.(check bool) "cookie-2 http_only" false (Cookeio.http_only cookie2);
79
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
116
+
Alcotest.(check (option expiration_testable))
"cookie-2 expires" None (Cookeio.expires cookie2);
(* Test cookie-3: non-session cookie with expiry *)
let cookie3 = find_cookie "cookie-3" in
let expected_expiry = Ptime.of_float_s 1257894000.0 in
85
-
Alcotest.(check string) "cookie-3 domain" "example.com" (Cookeio.domain cookie3);
122
+
Alcotest.(check string)
123
+
"cookie-3 domain" "example.com" (Cookeio.domain cookie3);
Alcotest.(check string) "cookie-3 path" "/foo/" (Cookeio.path cookie3);
Alcotest.(check string) "cookie-3 name" "cookie-3" (Cookeio.name cookie3);
Alcotest.(check string) "cookie-3 value" "v$3" (Cookeio.value cookie3);
Alcotest.(check bool) "cookie-3 secure" false (Cookeio.secure cookie3);
Alcotest.(check bool) "cookie-3 http_only" false (Cookeio.http_only cookie3);
91
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
92
-
"cookie-3 expires" expected_expiry (Cookeio.expires cookie3);
129
+
begin match expected_expiry with
131
+
Alcotest.(check (option expiration_testable))
133
+
(Some (`DateTime t))
134
+
(Cookeio.expires cookie3)
135
+
| None -> Alcotest.fail "Expected expiry time for cookie-3"
(* Test cookie-4: another non-session cookie *)
let cookie4 = find_cookie "cookie-4" in
96
-
Alcotest.(check string) "cookie-4 domain" "example.com" (Cookeio.domain cookie4);
140
+
Alcotest.(check string)
141
+
"cookie-4 domain" "example.com" (Cookeio.domain cookie4);
Alcotest.(check string) "cookie-4 path" "/foo/" (Cookeio.path cookie4);
Alcotest.(check string) "cookie-4 name" "cookie-4" (Cookeio.name cookie4);
Alcotest.(check string) "cookie-4 value" "v$4" (Cookeio.value cookie4);
Alcotest.(check bool) "cookie-4 secure" false (Cookeio.secure cookie4);
Alcotest.(check bool) "cookie-4 http_only" false (Cookeio.http_only cookie4);
102
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
103
-
"cookie-4 expires" expected_expiry (Cookeio.expires cookie4);
147
+
begin match expected_expiry with
149
+
Alcotest.(check (option expiration_testable))
151
+
(Some (`DateTime t))
152
+
(Cookeio.expires cookie4)
153
+
| None -> Alcotest.fail "Expected expiry time for cookie-4"
(* Test cookie-5: secure cookie *)
let cookie5 = find_cookie "cookie-5" in
107
-
Alcotest.(check string) "cookie-5 domain" "example.com" (Cookeio.domain cookie5);
158
+
Alcotest.(check string)
159
+
"cookie-5 domain" "example.com" (Cookeio.domain cookie5);
Alcotest.(check string) "cookie-5 path" "/foo/" (Cookeio.path cookie5);
Alcotest.(check string) "cookie-5 name" "cookie-5" (Cookeio.name cookie5);
Alcotest.(check string) "cookie-5 value" "v$5" (Cookeio.value cookie5);
Alcotest.(check bool) "cookie-5 secure" true (Cookeio.secure cookie5);
Alcotest.(check bool) "cookie-5 http_only" false (Cookeio.http_only cookie5);
113
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
114
-
"cookie-5 expires" expected_expiry (Cookeio.expires cookie5)
165
+
begin match expected_expiry with
167
+
Alcotest.(check (option expiration_testable))
169
+
(Some (`DateTime t))
170
+
(Cookeio.expires cookie5)
171
+
| None -> Alcotest.fail "Expected expiry time for cookie-5"
let test_load_from_file env =
(* This test loads from the actual test/cookies.txt file using the load function *)
···
(* Verify a few key cookies are loaded correctly *)
let cookie1 = find_cookie "cookie-1" in
Alcotest.(check string) "file cookie-1 value" "v$1" (Cookeio.value cookie1);
132
-
Alcotest.(check string) "file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
190
+
Alcotest.(check string)
191
+
"file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
Alcotest.(check bool) "file cookie-1 secure" false (Cookeio.secure cookie1);
134
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
193
+
Alcotest.(check (option expiration_testable))
"file cookie-1 expires" None (Cookeio.expires cookie1);
let cookie5 = find_cookie "cookie-5" in
Alcotest.(check string) "file cookie-5 value" "v$5" (Cookeio.value cookie5);
Alcotest.(check bool) "file cookie-5 secure" true (Cookeio.secure cookie5);
let expected_expiry = Ptime.of_float_s 1257894000.0 in
141
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
142
-
"file cookie-5 expires" expected_expiry (Cookeio.expires cookie5);
200
+
begin match expected_expiry with
202
+
Alcotest.(check (option expiration_testable))
203
+
"file cookie-5 expires"
204
+
(Some (`DateTime t))
205
+
(Cookeio.expires cookie5)
206
+
| None -> Alcotest.fail "Expected expiry time for cookie-5"
(* Verify subdomain cookie *)
let cookie2 = find_cookie "cookie-2" in
146
-
Alcotest.(check string) "file cookie-2 domain" ".example.com" (Cookeio.domain cookie2);
147
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
211
+
Alcotest.(check string)
212
+
"file cookie-2 domain" "example.com" (Cookeio.domain cookie2);
213
+
Alcotest.(check (option expiration_testable))
"file cookie-2 expires" None (Cookeio.expires cookie2)
let test_cookie_matching env =
···
(* Add test cookies with different domain patterns *)
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"exact" ~value:"test1"
157
-
~secure:false ~http_only:false ?expires:None ?same_site:None
223
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
161
-
Cookeio.make ~domain:".example.com" ~path:"/" ~name:"subdomain" ~value:"test2"
162
-
~secure:false ~http_only:false ?expires:None ?same_site:None
163
-
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
227
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"subdomain"
228
+
~value:"test2" ~secure:false ~http_only:false ?expires:None
229
+
?same_site:None ?max_age:None ~creation_time:Ptime.epoch
230
+
~last_access:Ptime.epoch ()
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"secure" ~value:"test3"
167
-
~secure:true ~http_only:false ?expires:None ?same_site:None
234
+
~secure:true ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
···
add_cookie jar subdomain_cookie;
add_cookie jar secure_cookie;
175
-
(* Test exact domain matching *)
242
+
(* Test exact domain matching - all three cookies should match example.com *)
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
···
Alcotest.(check int) "https cookies count" 3 (List.length cookies_https);
186
-
(* Test subdomain matching *)
253
+
(* Test subdomain matching - all cookies should match subdomains now *)
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
190
-
Alcotest.(check int) "subdomain cookies count" 1 (List.length cookies_sub);
191
-
let sub_cookie = List.hd cookies_sub in
192
-
Alcotest.(check string) "subdomain cookie name" "subdomain" (Cookeio.name sub_cookie)
257
+
Alcotest.(check int) "subdomain cookies count" 2 (List.length cookies_sub)
let clock = Eio.Stdenv.clock env in
···
212
-
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test" ~value:"value"
213
-
~secure:true ~http_only:false ?expires:(Ptime.of_float_s 1257894000.0)
214
-
~same_site:`Strict ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
278
+
match Ptime.of_float_s 1257894000.0 with
279
+
| Some t -> Some (`DateTime t)
282
+
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test"
283
+
~value:"value" ~secure:true ~http_only:false ?expires ~same_site:`Strict
284
+
?max_age:None ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
add_cookie jar test_cookie;
···
let cookie2 = List.hd cookies2 in
Alcotest.(check string) "round trip name" "test" (Cookeio.name cookie2);
Alcotest.(check string) "round trip value" "value" (Cookeio.value cookie2);
228
-
Alcotest.(check string) "round trip domain" "example.com" (Cookeio.domain cookie2);
298
+
Alcotest.(check string)
299
+
"round trip domain" "example.com" (Cookeio.domain cookie2);
Alcotest.(check string) "round trip path" "/test/" (Cookeio.path cookie2);
Alcotest.(check bool) "round trip secure" true (Cookeio.secure cookie2);
(* Note: http_only and same_site are lost in Mozilla format *)
303
+
begin match Ptime.of_float_s 1257894000.0 with
305
+
Alcotest.(check (option expiration_testable))
306
+
"round trip expires"
307
+
(Some (`DateTime t))
308
+
(Cookeio.expires cookie2)
309
+
| None -> Alcotest.fail "Expected expiry time"
312
+
let test_cookie_expiry_with_mock_clock () =
313
+
Eio_mock.Backend.run @@ fun () ->
314
+
let clock = Eio_mock.Clock.make () in
316
+
(* Start at time 1000.0 for convenience *)
317
+
Eio_mock.Clock.set_time clock 1000.0;
319
+
let jar = create () in
321
+
(* Add a cookie that expires at time 1500.0 (expires in 500 seconds) *)
322
+
let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in
324
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon"
325
+
~value:"value1" ~secure:false ~http_only:false
326
+
~expires:(`DateTime expires_soon) ?same_site:None ?max_age:None
327
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
328
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
332
+
(* Add a cookie that expires at time 2000.0 (expires in 1000 seconds) *)
333
+
let expires_later = Ptime.of_float_s 2000.0 |> Option.get in
335
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later"
336
+
~value:"value2" ~secure:false ~http_only:false
337
+
~expires:(`DateTime expires_later) ?same_site:None ?max_age:None
338
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
339
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
343
+
(* Add a session cookie (no expiry) *)
345
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"value3"
346
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
347
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
348
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
352
+
add_cookie jar cookie1;
353
+
add_cookie jar cookie2;
354
+
add_cookie jar cookie3;
356
+
Alcotest.(check int) "initial count" 3 (count jar);
358
+
(* Advance time to 1600.0 - first cookie should expire *)
359
+
Eio_mock.Clock.set_time clock 1600.0;
360
+
clear_expired jar ~clock;
362
+
Alcotest.(check int) "after first expiry" 2 (count jar);
364
+
let cookies = get_all_cookies jar in
365
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
366
+
Alcotest.(check (list string))
367
+
"remaining cookies after 1600s"
368
+
[ "expires_later"; "session" ]
371
+
(* Advance time to 2100.0 - second cookie should expire *)
372
+
Eio_mock.Clock.set_time clock 2100.0;
373
+
clear_expired jar ~clock;
375
+
Alcotest.(check int) "after second expiry" 1 (count jar);
377
+
let remaining = get_all_cookies jar in
378
+
Alcotest.(check string)
379
+
"only session cookie remains" "session"
380
+
(Cookeio.name (List.hd remaining))
382
+
let test_get_cookies_filters_expired () =
383
+
Eio_mock.Backend.run @@ fun () ->
384
+
let clock = Eio_mock.Clock.make () in
385
+
Eio_mock.Clock.set_time clock 1000.0;
387
+
let jar = create () in
389
+
(* Add an expired cookie (expired at time 500) *)
390
+
let expired = Ptime.of_float_s 500.0 |> Option.get in
391
+
let cookie_expired =
392
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expired"
393
+
~value:"old" ~secure:false ~http_only:false
394
+
~expires:(`DateTime expired)
395
+
~creation_time:(Ptime.of_float_s 100.0 |> Option.get)
396
+
~last_access:(Ptime.of_float_s 100.0 |> Option.get)
400
+
(* Add a valid cookie (expires at time 2000) *)
401
+
let valid_time = Ptime.of_float_s 2000.0 |> Option.get in
403
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"valid"
404
+
~value:"current" ~secure:false ~http_only:false
405
+
~expires:(`DateTime valid_time)
406
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
407
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
411
+
(* Add a session cookie (no expiry) *)
412
+
let cookie_session =
413
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session"
414
+
~value:"sess" ~secure:false ~http_only:false
415
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
416
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
420
+
add_cookie jar cookie_expired;
421
+
add_cookie jar cookie_valid;
422
+
add_cookie jar cookie_session;
424
+
(* get_all_cookies returns all including expired (for inspection) *)
425
+
Alcotest.(check int) "get_all_cookies includes expired" 3
426
+
(List.length (get_all_cookies jar));
428
+
(* get_cookies should automatically filter out expired cookies *)
430
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
432
+
Alcotest.(check int) "get_cookies filters expired" 2 (List.length cookies);
434
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
435
+
Alcotest.(check (list string))
436
+
"only non-expired cookies returned"
437
+
[ "session"; "valid" ]
440
+
let test_max_age_parsing_with_mock_clock () =
441
+
Eio_mock.Backend.run @@ fun () ->
442
+
let clock = Eio_mock.Clock.make () in
444
+
(* Start at a known time *)
445
+
Eio_mock.Clock.set_time clock 5000.0;
447
+
(* Parse a Set-Cookie header with Max-Age *)
448
+
let header = "session=abc123; Max-Age=3600; Secure; HttpOnly" in
450
+
of_set_cookie_header
452
+
Ptime.of_float_s (Eio.Time.now clock)
453
+
|> Option.value ~default:Ptime.epoch)
454
+
~domain:"example.com" ~path:"/" header
457
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
459
+
let cookie = Result.get_ok cookie_opt in
460
+
Alcotest.(check string) "cookie name" "session" (Cookeio.name cookie);
461
+
Alcotest.(check string) "cookie value" "abc123" (Cookeio.value cookie);
462
+
Alcotest.(check bool) "cookie secure" true (Cookeio.secure cookie);
463
+
Alcotest.(check bool) "cookie http_only" true (Cookeio.http_only cookie);
465
+
(* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *)
466
+
let expected_expiry = Ptime.of_float_s 8600.0 in
467
+
begin match expected_expiry with
469
+
Alcotest.(check (option expiration_testable))
470
+
"expires set from max-age"
471
+
(Some (`DateTime t))
472
+
(Cookeio.expires cookie)
473
+
| None -> Alcotest.fail "Expected expiry time"
476
+
(* Verify creation time matches clock time *)
477
+
let expected_creation = Ptime.of_float_s 5000.0 in
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
233
-
"round trip expires"
234
-
(Ptime.of_float_s 1257894000.0)
235
-
(Cookeio.expires cookie2)
479
+
"creation time" expected_creation
480
+
(Some (Cookeio.creation_time cookie))
482
+
let test_last_access_time_with_mock_clock () =
483
+
Eio_mock.Backend.run @@ fun () ->
484
+
let clock = Eio_mock.Clock.make () in
486
+
(* Start at time 3000.0 *)
487
+
Eio_mock.Clock.set_time clock 3000.0;
489
+
let jar = create () in
493
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
494
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
495
+
~creation_time:(Ptime.of_float_s 3000.0 |> Option.get)
496
+
~last_access:(Ptime.of_float_s 3000.0 |> Option.get)
499
+
add_cookie jar cookie;
501
+
(* Verify initial last access time *)
502
+
let cookies1 = get_all_cookies jar in
503
+
let cookie1 = List.hd cookies1 in
504
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
505
+
"initial last access" (Ptime.of_float_s 3000.0)
506
+
(Some (Cookeio.last_access cookie1));
508
+
(* Advance time to 4000.0 *)
509
+
Eio_mock.Clock.set_time clock 4000.0;
511
+
(* Get cookies, which should update last access time to current clock time *)
513
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
516
+
(* Verify last access time was updated to the new clock time *)
517
+
let cookies2 = get_all_cookies jar in
518
+
let cookie2 = List.hd cookies2 in
519
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
520
+
"updated last access" (Ptime.of_float_s 4000.0)
521
+
(Some (Cookeio.last_access cookie2))
523
+
let test_of_set_cookie_header_with_expires () =
524
+
Eio_mock.Backend.run @@ fun () ->
525
+
let clock = Eio_mock.Clock.make () in
527
+
(* Start at a known time *)
528
+
Eio_mock.Clock.set_time clock 6000.0;
530
+
(* Use RFC3339 format which is what Ptime.of_rfc3339 expects *)
532
+
"id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com"
535
+
of_set_cookie_header
537
+
Ptime.of_float_s (Eio.Time.now clock)
538
+
|> Option.value ~default:Ptime.epoch)
539
+
~domain:"example.com" ~path:"/" header
542
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
544
+
let cookie = Result.get_ok cookie_opt in
545
+
Alcotest.(check string) "cookie name" "id" (Cookeio.name cookie);
546
+
Alcotest.(check string) "cookie value" "xyz789" (Cookeio.value cookie);
547
+
Alcotest.(check string) "cookie domain" "example.com" (Cookeio.domain cookie);
548
+
Alcotest.(check string) "cookie path" "/" (Cookeio.path cookie);
550
+
(* Verify expires is parsed correctly *)
551
+
Alcotest.(check bool)
553
+
(Option.is_some (Cookeio.expires cookie));
555
+
(* Verify the specific expiry time parsed from the RFC3339 date *)
556
+
let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
557
+
match expected_expiry with
558
+
| Ok (time, _, _) ->
559
+
Alcotest.(check (option expiration_testable))
560
+
"expires matches parsed value"
561
+
(Some (`DateTime time))
562
+
(Cookeio.expires cookie)
563
+
| Error _ -> Alcotest.fail "Failed to parse expected expiry time"
565
+
let test_samesite_none_validation () =
566
+
Eio_mock.Backend.run @@ fun () ->
567
+
let clock = Eio_mock.Clock.make () in
569
+
(* Start at a known time *)
570
+
Eio_mock.Clock.set_time clock 7000.0;
572
+
(* This should be rejected: SameSite=None without Secure *)
573
+
let invalid_header = "token=abc; SameSite=None" in
575
+
of_set_cookie_header
577
+
Ptime.of_float_s (Eio.Time.now clock)
578
+
|> Option.value ~default:Ptime.epoch)
579
+
~domain:"example.com" ~path:"/" invalid_header
582
+
Alcotest.(check bool)
583
+
"invalid cookie rejected" true
584
+
(Result.is_error cookie_opt);
586
+
(* This should be accepted: SameSite=None with Secure *)
587
+
let valid_header = "token=abc; SameSite=None; Secure" in
589
+
of_set_cookie_header
591
+
Ptime.of_float_s (Eio.Time.now clock)
592
+
|> Option.value ~default:Ptime.epoch)
593
+
~domain:"example.com" ~path:"/" valid_header
596
+
Alcotest.(check bool)
597
+
"valid cookie accepted" true
598
+
(Result.is_ok cookie_opt2);
600
+
let cookie = Result.get_ok cookie_opt2 in
601
+
Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie);
606
+
(fun ppf -> function
607
+
| `Strict -> Format.pp_print_string ppf "Strict"
608
+
| `Lax -> Format.pp_print_string ppf "Lax"
609
+
| `None -> Format.pp_print_string ppf "None")
611
+
"samesite is None" (Some `None) (Cookeio.same_site cookie)
613
+
let test_domain_normalization () =
614
+
Eio_mock.Backend.run @@ fun () ->
615
+
let clock = Eio_mock.Clock.make () in
616
+
Eio_mock.Clock.set_time clock 1000.0;
618
+
(* Test parsing ".example.com" stores as "example.com" *)
619
+
let header = "test=value; Domain=.example.com" in
621
+
of_set_cookie_header
623
+
Ptime.of_float_s (Eio.Time.now clock)
624
+
|> Option.value ~default:Ptime.epoch)
625
+
~domain:"example.com" ~path:"/" header
627
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
628
+
let cookie = Result.get_ok cookie_opt in
629
+
Alcotest.(check string)
630
+
"domain normalized" "example.com" (Cookeio.domain cookie);
632
+
(* Test round-trip through Mozilla format normalizes domains *)
633
+
let jar = create () in
635
+
Cookeio.make ~domain:".example.com" ~path:"/" ~name:"test" ~value:"val"
636
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
637
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
638
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
641
+
add_cookie jar test_cookie;
643
+
let mozilla_format = to_mozilla_format jar in
644
+
let jar2 = from_mozilla_format ~clock mozilla_format in
645
+
let cookies2 = get_all_cookies jar2 in
646
+
Alcotest.(check int) "one cookie" 1 (List.length cookies2);
647
+
Alcotest.(check string)
648
+
"domain normalized after round-trip" "example.com"
649
+
(Cookeio.domain (List.hd cookies2))
651
+
let test_max_age_stored_separately () =
652
+
Eio_mock.Backend.run @@ fun () ->
653
+
let clock = Eio_mock.Clock.make () in
654
+
Eio_mock.Clock.set_time clock 5000.0;
656
+
(* Parse a Set-Cookie header with Max-Age *)
657
+
let header = "session=abc123; Max-Age=3600" in
659
+
of_set_cookie_header
661
+
Ptime.of_float_s (Eio.Time.now clock)
662
+
|> Option.value ~default:Ptime.epoch)
663
+
~domain:"example.com" ~path:"/" header
665
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
667
+
let cookie = Result.get_ok cookie_opt in
669
+
(* Verify max_age is stored as a Ptime.Span *)
670
+
Alcotest.(check bool)
671
+
"max_age is set" true
672
+
(Option.is_some (Cookeio.max_age cookie));
673
+
let max_age_span = Option.get (Cookeio.max_age cookie) in
674
+
Alcotest.(check (option int))
675
+
"max_age is 3600 seconds" (Some 3600)
676
+
(Ptime.Span.to_int_s max_age_span);
678
+
(* Verify expires is also computed correctly *)
679
+
let expected_expiry = Ptime.of_float_s 8600.0 in
680
+
begin match expected_expiry with
682
+
Alcotest.(check (option expiration_testable))
683
+
"expires computed from max-age"
684
+
(Some (`DateTime t))
685
+
(Cookeio.expires cookie)
686
+
| None -> Alcotest.fail "Expected expiry time"
689
+
let test_max_age_negative_becomes_zero () =
690
+
Eio_mock.Backend.run @@ fun () ->
691
+
let clock = Eio_mock.Clock.make () in
692
+
Eio_mock.Clock.set_time clock 5000.0;
694
+
(* Parse a Set-Cookie header with negative Max-Age *)
695
+
let header = "session=abc123; Max-Age=-100" in
697
+
of_set_cookie_header
699
+
Ptime.of_float_s (Eio.Time.now clock)
700
+
|> Option.value ~default:Ptime.epoch)
701
+
~domain:"example.com" ~path:"/" header
703
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
705
+
let cookie = Result.get_ok cookie_opt in
707
+
(* Verify max_age is stored as 0 per RFC 6265 *)
708
+
Alcotest.(check bool)
709
+
"max_age is set" true
710
+
(Option.is_some (Cookeio.max_age cookie));
711
+
let max_age_span = Option.get (Cookeio.max_age cookie) in
712
+
Alcotest.(check (option int))
713
+
"negative max_age becomes 0" (Some 0)
714
+
(Ptime.Span.to_int_s max_age_span);
716
+
(* Verify expires is computed with 0 seconds *)
717
+
let expected_expiry = Ptime.of_float_s 5000.0 in
718
+
begin match expected_expiry with
720
+
Alcotest.(check (option expiration_testable))
721
+
"expires computed with 0 seconds"
722
+
(Some (`DateTime t))
723
+
(Cookeio.expires cookie)
724
+
| None -> Alcotest.fail "Expected expiry time"
727
+
let string_contains_substring s sub =
729
+
let len = String.length sub in
731
+
if i + len > String.length s then false
732
+
else if String.sub s i len = sub then true
733
+
else search (i + 1)
738
+
let test_make_set_cookie_header_includes_max_age () =
739
+
Eio_mock.Backend.run @@ fun () ->
740
+
let clock = Eio_mock.Clock.make () in
741
+
Eio_mock.Clock.set_time clock 5000.0;
743
+
(* Create a cookie with max_age *)
744
+
let max_age_span = Ptime.Span.of_int_s 3600 in
745
+
let expires_time = Ptime.of_float_s 8600.0 |> Option.get in
747
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"abc123"
748
+
~secure:true ~http_only:true
749
+
?expires:(Some (`DateTime expires_time))
750
+
?max_age:(Some max_age_span) ?same_site:(Some `Strict)
751
+
~creation_time:(Ptime.of_float_s 5000.0 |> Option.get)
752
+
~last_access:(Ptime.of_float_s 5000.0 |> Option.get)
756
+
let header = make_set_cookie_header cookie in
758
+
(* Verify the header includes Max-Age *)
759
+
Alcotest.(check bool)
760
+
"header includes Max-Age" true
761
+
(string_contains_substring header "Max-Age=3600");
763
+
(* Verify the header includes Expires *)
764
+
Alcotest.(check bool)
765
+
"header includes Expires" true
766
+
(string_contains_substring header "Expires=");
768
+
(* Verify the header includes other attributes *)
769
+
Alcotest.(check bool)
770
+
"header includes Secure" true
771
+
(string_contains_substring header "Secure");
772
+
Alcotest.(check bool)
773
+
"header includes HttpOnly" true
774
+
(string_contains_substring header "HttpOnly");
775
+
Alcotest.(check bool)
776
+
"header includes SameSite" true
777
+
(string_contains_substring header "SameSite=Strict")
779
+
let test_max_age_round_trip () =
780
+
Eio_mock.Backend.run @@ fun () ->
781
+
let clock = Eio_mock.Clock.make () in
782
+
Eio_mock.Clock.set_time clock 5000.0;
784
+
(* Parse a cookie with Max-Age *)
785
+
let header = "session=xyz; Max-Age=7200; Secure; HttpOnly" in
787
+
of_set_cookie_header
789
+
Ptime.of_float_s (Eio.Time.now clock)
790
+
|> Option.value ~default:Ptime.epoch)
791
+
~domain:"example.com" ~path:"/" header
793
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
794
+
let cookie = Result.get_ok cookie_opt in
796
+
(* Generate Set-Cookie header from the cookie *)
797
+
let set_cookie_header = make_set_cookie_header cookie in
799
+
(* Parse it back *)
800
+
Eio_mock.Clock.set_time clock 5000.0;
801
+
(* Reset clock to same time *)
803
+
of_set_cookie_header
805
+
Ptime.of_float_s (Eio.Time.now clock)
806
+
|> Option.value ~default:Ptime.epoch)
807
+
~domain:"example.com" ~path:"/" set_cookie_header
809
+
Alcotest.(check bool) "cookie re-parsed" true (Result.is_ok cookie2_opt);
810
+
let cookie2 = Result.get_ok cookie2_opt in
812
+
(* Verify max_age is preserved *)
813
+
Alcotest.(check (option int))
814
+
"max_age preserved"
815
+
(Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie)))
816
+
(Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie2)))
818
+
let test_domain_matching () =
819
+
Eio_mock.Backend.run @@ fun () ->
820
+
let clock = Eio_mock.Clock.make () in
821
+
Eio_mock.Clock.set_time clock 2000.0;
823
+
let jar = create () in
825
+
(* Create a cookie with domain "example.com" *)
827
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
828
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
829
+
~creation_time:(Ptime.of_float_s 2000.0 |> Option.get)
830
+
~last_access:(Ptime.of_float_s 2000.0 |> Option.get)
833
+
add_cookie jar cookie;
835
+
(* Test "example.com" cookie matches "example.com" request *)
837
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
839
+
Alcotest.(check int) "matches exact domain" 1 (List.length cookies1);
841
+
(* Test "example.com" cookie matches "sub.example.com" request *)
843
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
845
+
Alcotest.(check int) "matches subdomain" 1 (List.length cookies2);
847
+
(* Test "example.com" cookie matches "deep.sub.example.com" request *)
849
+
get_cookies jar ~clock ~domain:"deep.sub.example.com" ~path:"/"
852
+
Alcotest.(check int) "matches deep subdomain" 1 (List.length cookies3);
854
+
(* Test "example.com" cookie doesn't match "notexample.com" *)
856
+
get_cookies jar ~clock ~domain:"notexample.com" ~path:"/" ~is_secure:false
858
+
Alcotest.(check int) "doesn't match different domain" 0 (List.length cookies4);
860
+
(* Test "example.com" cookie doesn't match "fakeexample.com" *)
862
+
get_cookies jar ~clock ~domain:"fakeexample.com" ~path:"/" ~is_secure:false
864
+
Alcotest.(check int) "doesn't match prefix domain" 0 (List.length cookies5)
866
+
(** {1 HTTP Date Parsing Tests} *)
868
+
let test_http_date_fmt1 () =
869
+
Eio_mock.Backend.run @@ fun () ->
870
+
let clock = Eio_mock.Clock.make () in
871
+
Eio_mock.Clock.set_time clock 1000.0;
873
+
(* Test FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
874
+
let header = "session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT" in
876
+
of_set_cookie_header
878
+
Ptime.of_float_s (Eio.Time.now clock)
879
+
|> Option.value ~default:Ptime.epoch)
880
+
~domain:"example.com" ~path:"/" header
882
+
Alcotest.(check bool) "FMT1 cookie parsed" true (Result.is_ok cookie_opt);
884
+
let cookie = Result.get_ok cookie_opt in
885
+
Alcotest.(check bool)
886
+
"FMT1 has expiry" true
887
+
(Option.is_some (Cookeio.expires cookie));
889
+
(* Verify the parsed time matches expected value *)
890
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
891
+
begin match expected with
893
+
Alcotest.(check (option expiration_testable))
894
+
"FMT1 expiry correct"
895
+
(Some (`DateTime t))
896
+
(Cookeio.expires cookie)
897
+
| None -> Alcotest.fail "Expected expiry time for FMT1"
900
+
let test_http_date_fmt2 () =
901
+
Eio_mock.Backend.run @@ fun () ->
902
+
let clock = Eio_mock.Clock.make () in
903
+
Eio_mock.Clock.set_time clock 1000.0;
905
+
(* Test FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850 with abbreviated year) *)
906
+
let header = "session=abc; Expires=Wednesday, 21-Oct-15 07:28:00 GMT" in
908
+
of_set_cookie_header
910
+
Ptime.of_float_s (Eio.Time.now clock)
911
+
|> Option.value ~default:Ptime.epoch)
912
+
~domain:"example.com" ~path:"/" header
914
+
Alcotest.(check bool) "FMT2 cookie parsed" true (Result.is_ok cookie_opt);
916
+
let cookie = Result.get_ok cookie_opt in
917
+
Alcotest.(check bool)
918
+
"FMT2 has expiry" true
919
+
(Option.is_some (Cookeio.expires cookie));
921
+
(* Year 15 should be normalized to 2015 *)
922
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
923
+
begin match expected with
925
+
Alcotest.(check (option expiration_testable))
926
+
"FMT2 expiry correct with year normalization"
927
+
(Some (`DateTime t))
928
+
(Cookeio.expires cookie)
929
+
| None -> Alcotest.fail "Expected expiry time for FMT2"
932
+
let test_http_date_fmt3 () =
933
+
Eio_mock.Backend.run @@ fun () ->
934
+
let clock = Eio_mock.Clock.make () in
935
+
Eio_mock.Clock.set_time clock 1000.0;
937
+
(* Test FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
938
+
let header = "session=abc; Expires=Wed Oct 21 07:28:00 2015" in
940
+
of_set_cookie_header
942
+
Ptime.of_float_s (Eio.Time.now clock)
943
+
|> Option.value ~default:Ptime.epoch)
944
+
~domain:"example.com" ~path:"/" header
946
+
Alcotest.(check bool) "FMT3 cookie parsed" true (Result.is_ok cookie_opt);
948
+
let cookie = Result.get_ok cookie_opt in
949
+
Alcotest.(check bool)
950
+
"FMT3 has expiry" true
951
+
(Option.is_some (Cookeio.expires cookie));
953
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
954
+
begin match expected with
956
+
Alcotest.(check (option expiration_testable))
957
+
"FMT3 expiry correct"
958
+
(Some (`DateTime t))
959
+
(Cookeio.expires cookie)
960
+
| None -> Alcotest.fail "Expected expiry time for FMT3"
963
+
let test_http_date_fmt4 () =
964
+
Eio_mock.Backend.run @@ fun () ->
965
+
let clock = Eio_mock.Clock.make () in
966
+
Eio_mock.Clock.set_time clock 1000.0;
968
+
(* Test FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
969
+
let header = "session=abc; Expires=Wed, 21-Oct-2015 07:28:00 GMT" in
971
+
of_set_cookie_header
973
+
Ptime.of_float_s (Eio.Time.now clock)
974
+
|> Option.value ~default:Ptime.epoch)
975
+
~domain:"example.com" ~path:"/" header
977
+
Alcotest.(check bool) "FMT4 cookie parsed" true (Result.is_ok cookie_opt);
979
+
let cookie = Result.get_ok cookie_opt in
980
+
Alcotest.(check bool)
981
+
"FMT4 has expiry" true
982
+
(Option.is_some (Cookeio.expires cookie));
984
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
985
+
begin match expected with
987
+
Alcotest.(check (option expiration_testable))
988
+
"FMT4 expiry correct"
989
+
(Some (`DateTime t))
990
+
(Cookeio.expires cookie)
991
+
| None -> Alcotest.fail "Expected expiry time for FMT4"
994
+
let test_abbreviated_year_69_to_99 () =
995
+
Eio_mock.Backend.run @@ fun () ->
996
+
let clock = Eio_mock.Clock.make () in
997
+
Eio_mock.Clock.set_time clock 1000.0;
999
+
(* Year 95 should become 1995 *)
1000
+
let header = "session=abc; Expires=Wed, 21-Oct-95 07:28:00 GMT" in
1002
+
of_set_cookie_header
1004
+
Ptime.of_float_s (Eio.Time.now clock)
1005
+
|> Option.value ~default:Ptime.epoch)
1006
+
~domain:"example.com" ~path:"/" header
1008
+
let cookie = Result.get_ok cookie_opt in
1009
+
let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
1010
+
begin match expected with
1012
+
Alcotest.(check (option expiration_testable))
1013
+
"year 95 becomes 1995"
1014
+
(Some (`DateTime t))
1015
+
(Cookeio.expires cookie)
1016
+
| None -> Alcotest.fail "Expected expiry time for year 95"
1019
+
(* Year 69 should become 1969 *)
1020
+
let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in
1022
+
of_set_cookie_header
1024
+
Ptime.of_float_s (Eio.Time.now clock)
1025
+
|> Option.value ~default:Ptime.epoch)
1026
+
~domain:"example.com" ~path:"/" header2
1028
+
let cookie2 = Result.get_ok cookie_opt2 in
1029
+
let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
1030
+
begin match expected2 with
1032
+
Alcotest.(check (option expiration_testable))
1033
+
"year 69 becomes 1969"
1034
+
(Some (`DateTime t))
1035
+
(Cookeio.expires cookie2)
1036
+
| None -> Alcotest.fail "Expected expiry time for year 69"
1039
+
(* Year 99 should become 1999 *)
1040
+
let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in
1042
+
of_set_cookie_header
1044
+
Ptime.of_float_s (Eio.Time.now clock)
1045
+
|> Option.value ~default:Ptime.epoch)
1046
+
~domain:"example.com" ~path:"/" header3
1048
+
let cookie3 = Result.get_ok cookie_opt3 in
1049
+
let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
1050
+
begin match expected3 with
1052
+
Alcotest.(check (option expiration_testable))
1053
+
"year 99 becomes 1999"
1054
+
(Some (`DateTime t))
1055
+
(Cookeio.expires cookie3)
1056
+
| None -> Alcotest.fail "Expected expiry time for year 99"
1059
+
let test_abbreviated_year_0_to_68 () =
1060
+
Eio_mock.Backend.run @@ fun () ->
1061
+
let clock = Eio_mock.Clock.make () in
1062
+
Eio_mock.Clock.set_time clock 1000.0;
1064
+
(* Year 25 should become 2025 *)
1065
+
let header = "session=abc; Expires=Wed, 21-Oct-25 07:28:00 GMT" in
1067
+
of_set_cookie_header
1069
+
Ptime.of_float_s (Eio.Time.now clock)
1070
+
|> Option.value ~default:Ptime.epoch)
1071
+
~domain:"example.com" ~path:"/" header
1073
+
let cookie = Result.get_ok cookie_opt in
1074
+
let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
1075
+
begin match expected with
1077
+
Alcotest.(check (option expiration_testable))
1078
+
"year 25 becomes 2025"
1079
+
(Some (`DateTime t))
1080
+
(Cookeio.expires cookie)
1081
+
| None -> Alcotest.fail "Expected expiry time for year 25"
1084
+
(* Year 0 should become 2000 *)
1085
+
let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in
1087
+
of_set_cookie_header
1089
+
Ptime.of_float_s (Eio.Time.now clock)
1090
+
|> Option.value ~default:Ptime.epoch)
1091
+
~domain:"example.com" ~path:"/" header2
1093
+
let cookie2 = Result.get_ok cookie_opt2 in
1094
+
let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
1095
+
begin match expected2 with
1097
+
Alcotest.(check (option expiration_testable))
1098
+
"year 0 becomes 2000"
1099
+
(Some (`DateTime t))
1100
+
(Cookeio.expires cookie2)
1101
+
| None -> Alcotest.fail "Expected expiry time for year 0"
1104
+
(* Year 68 should become 2068 *)
1105
+
let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in
1107
+
of_set_cookie_header
1109
+
Ptime.of_float_s (Eio.Time.now clock)
1110
+
|> Option.value ~default:Ptime.epoch)
1111
+
~domain:"example.com" ~path:"/" header3
1113
+
let cookie3 = Result.get_ok cookie_opt3 in
1114
+
let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
1115
+
begin match expected3 with
1117
+
Alcotest.(check (option expiration_testable))
1118
+
"year 68 becomes 2068"
1119
+
(Some (`DateTime t))
1120
+
(Cookeio.expires cookie3)
1121
+
| None -> Alcotest.fail "Expected expiry time for year 68"
1124
+
let test_rfc3339_still_works () =
1125
+
Eio_mock.Backend.run @@ fun () ->
1126
+
let clock = Eio_mock.Clock.make () in
1127
+
Eio_mock.Clock.set_time clock 1000.0;
1129
+
(* Ensure RFC 3339 format still works for backward compatibility *)
1130
+
let header = "session=abc; Expires=2025-10-21T07:28:00Z" in
1132
+
of_set_cookie_header
1134
+
Ptime.of_float_s (Eio.Time.now clock)
1135
+
|> Option.value ~default:Ptime.epoch)
1136
+
~domain:"example.com" ~path:"/" header
1138
+
Alcotest.(check bool)
1139
+
"RFC 3339 cookie parsed" true
1140
+
(Result.is_ok cookie_opt);
1142
+
let cookie = Result.get_ok cookie_opt in
1143
+
Alcotest.(check bool)
1144
+
"RFC 3339 has expiry" true
1145
+
(Option.is_some (Cookeio.expires cookie));
1147
+
(* Verify the time was parsed correctly *)
1148
+
let expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
1149
+
match expected with
1150
+
| Ok (time, _, _) ->
1151
+
Alcotest.(check (option expiration_testable))
1152
+
"RFC 3339 expiry correct"
1153
+
(Some (`DateTime time))
1154
+
(Cookeio.expires cookie)
1155
+
| Error _ -> Alcotest.fail "Failed to parse expected RFC 3339 time"
1157
+
let test_invalid_date_format_logs_warning () =
1158
+
Eio_mock.Backend.run @@ fun () ->
1159
+
let clock = Eio_mock.Clock.make () in
1160
+
Eio_mock.Clock.set_time clock 1000.0;
1162
+
(* Invalid date format should log a warning but still parse the cookie *)
1163
+
let header = "session=abc; Expires=InvalidDate" in
1165
+
of_set_cookie_header
1167
+
Ptime.of_float_s (Eio.Time.now clock)
1168
+
|> Option.value ~default:Ptime.epoch)
1169
+
~domain:"example.com" ~path:"/" header
1172
+
(* Cookie should still be parsed, just without expires *)
1173
+
Alcotest.(check bool)
1174
+
"cookie parsed despite invalid date" true
1175
+
(Result.is_ok cookie_opt);
1176
+
let cookie = Result.get_ok cookie_opt in
1177
+
Alcotest.(check string) "cookie name correct" "session" (Cookeio.name cookie);
1178
+
Alcotest.(check string) "cookie value correct" "abc" (Cookeio.value cookie);
1179
+
(* expires should be None since date was invalid *)
1180
+
Alcotest.(check (option expiration_testable))
1181
+
"expires is None for invalid date" None (Cookeio.expires cookie)
1183
+
let test_case_insensitive_month_parsing () =
1184
+
Eio_mock.Backend.run @@ fun () ->
1185
+
let clock = Eio_mock.Clock.make () in
1186
+
Eio_mock.Clock.set_time clock 1000.0;
1188
+
(* Test various case combinations for month names *)
1191
+
("session=abc; Expires=Wed, 21 oct 2015 07:28:00 GMT", "lowercase month");
1192
+
("session=abc; Expires=Wed, 21 OCT 2015 07:28:00 GMT", "uppercase month");
1193
+
("session=abc; Expires=Wed, 21 OcT 2015 07:28:00 GMT", "mixed case month");
1194
+
("session=abc; Expires=Wed, 21 oCt 2015 07:28:00 GMT", "weird case month");
1199
+
(fun (header, description) ->
1201
+
of_set_cookie_header
1203
+
Ptime.of_float_s (Eio.Time.now clock)
1204
+
|> Option.value ~default:Ptime.epoch)
1205
+
~domain:"example.com" ~path:"/" header
1207
+
Alcotest.(check bool)
1208
+
(description ^ " parsed") true
1209
+
(Result.is_ok cookie_opt);
1211
+
let cookie = Result.get_ok cookie_opt in
1212
+
Alcotest.(check bool)
1213
+
(description ^ " has expiry")
1215
+
(Option.is_some (Cookeio.expires cookie));
1217
+
(* Verify the date was parsed correctly regardless of case *)
1218
+
let expires = Option.get (Cookeio.expires cookie) in
1219
+
match expires with
1220
+
| `DateTime ptime ->
1221
+
let year, month, _ = Ptime.to_date ptime in
1222
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
1223
+
Alcotest.(check int)
1224
+
(description ^ " month correct (October=10)")
1226
+
| `Session -> Alcotest.fail (description ^ " should not be session cookie"))
1229
+
let test_case_insensitive_gmt_parsing () =
1230
+
Eio_mock.Backend.run @@ fun () ->
1231
+
let clock = Eio_mock.Clock.make () in
1232
+
Eio_mock.Clock.set_time clock 1000.0;
1234
+
(* Test various case combinations for GMT timezone *)
1237
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT", "uppercase GMT");
1238
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 gmt", "lowercase gmt");
1239
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 Gmt", "mixed case Gmt");
1240
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GmT", "weird case GmT");
1245
+
(fun (header, description) ->
1247
+
of_set_cookie_header
1249
+
Ptime.of_float_s (Eio.Time.now clock)
1250
+
|> Option.value ~default:Ptime.epoch)
1251
+
~domain:"example.com" ~path:"/" header
1253
+
Alcotest.(check bool)
1254
+
(description ^ " parsed") true
1255
+
(Result.is_ok cookie_opt);
1257
+
let cookie = Result.get_ok cookie_opt in
1258
+
Alcotest.(check bool)
1259
+
(description ^ " has expiry")
1261
+
(Option.is_some (Cookeio.expires cookie));
1263
+
(* Verify the date was parsed correctly regardless of GMT case *)
1264
+
let expires = Option.get (Cookeio.expires cookie) in
1265
+
match expires with
1266
+
| `DateTime ptime ->
1267
+
let year, month, day = Ptime.to_date ptime in
1268
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
1269
+
Alcotest.(check int)
1270
+
(description ^ " month correct (October=10)")
1272
+
Alcotest.(check int) (description ^ " day correct") 21 day
1273
+
| `Session -> Alcotest.fail (description ^ " should not be session cookie"))
1276
+
(** {1 Delta Tracking Tests} *)
1278
+
let test_add_original_not_in_delta () =
1279
+
Eio_mock.Backend.run @@ fun () ->
1280
+
let clock = Eio_mock.Clock.make () in
1281
+
Eio_mock.Clock.set_time clock 1000.0;
1283
+
let jar = create () in
1285
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1286
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1287
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1288
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1291
+
add_original jar cookie;
1293
+
(* Delta should be empty *)
1294
+
let delta = delta jar in
1295
+
Alcotest.(check int) "delta is empty" 0 (List.length delta);
1297
+
(* But the cookie should be in the jar *)
1298
+
Alcotest.(check int) "jar count is 1" 1 (count jar)
1300
+
let test_add_cookie_appears_in_delta () =
1301
+
Eio_mock.Backend.run @@ fun () ->
1302
+
let clock = Eio_mock.Clock.make () in
1303
+
Eio_mock.Clock.set_time clock 1000.0;
1305
+
let jar = create () in
1307
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1308
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1309
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1310
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1313
+
add_cookie jar cookie;
1315
+
(* Delta should contain the cookie *)
1316
+
let delta = delta jar in
1317
+
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
1318
+
let delta_cookie = List.hd delta in
1319
+
Alcotest.(check string) "delta cookie name" "test" (Cookeio.name delta_cookie);
1320
+
Alcotest.(check string)
1321
+
"delta cookie value" "value"
1322
+
(Cookeio.value delta_cookie)
1324
+
let test_remove_original_creates_removal_cookie () =
1325
+
Eio_mock.Backend.run @@ fun () ->
1326
+
let clock = Eio_mock.Clock.make () in
1327
+
Eio_mock.Clock.set_time clock 1000.0;
1329
+
let jar = create () in
1331
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1332
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1333
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1334
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1337
+
add_original jar cookie;
1339
+
(* Remove the cookie *)
1340
+
remove jar ~clock cookie;
1342
+
(* Delta should contain a removal cookie *)
1343
+
let delta = delta jar in
1344
+
Alcotest.(check int) "delta has 1 removal cookie" 1 (List.length delta);
1345
+
let removal_cookie = List.hd delta in
1346
+
Alcotest.(check string)
1347
+
"removal cookie name" "test"
1348
+
(Cookeio.name removal_cookie);
1349
+
Alcotest.(check string)
1350
+
"removal cookie has empty value" ""
1351
+
(Cookeio.value removal_cookie);
1353
+
(* Check Max-Age is 0 *)
1354
+
match Cookeio.max_age removal_cookie with
1356
+
Alcotest.(check (option int))
1357
+
"removal cookie Max-Age is 0" (Some 0) (Ptime.Span.to_int_s span)
1358
+
| None -> Alcotest.fail "removal cookie should have Max-Age"
1360
+
let test_remove_delta_cookie_removes_it () =
1361
+
Eio_mock.Backend.run @@ fun () ->
1362
+
let clock = Eio_mock.Clock.make () in
1363
+
Eio_mock.Clock.set_time clock 1000.0;
1365
+
let jar = create () in
1367
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1368
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1369
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1370
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1373
+
add_cookie jar cookie;
1375
+
(* Remove the cookie *)
1376
+
remove jar ~clock cookie;
1378
+
(* Delta should be empty *)
1379
+
let delta = delta jar in
1380
+
Alcotest.(check int)
1381
+
"delta is empty after removing delta cookie" 0 (List.length delta)
1383
+
let test_get_cookies_combines_original_and_delta () =
1384
+
Eio_mock.Backend.run @@ fun () ->
1385
+
let clock = Eio_mock.Clock.make () in
1386
+
Eio_mock.Clock.set_time clock 1000.0;
1388
+
let jar = create () in
1390
+
(* Add an original cookie *)
1392
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"original"
1393
+
~value:"orig_val" ~secure:false ~http_only:false ?expires:None
1394
+
?same_site:None ?max_age:None
1395
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1396
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1399
+
add_original jar original;
1401
+
(* Add a delta cookie *)
1402
+
let delta_cookie =
1403
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"delta"
1404
+
~value:"delta_val" ~secure:false ~http_only:false ?expires:None
1405
+
?same_site:None ?max_age:None
1406
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1407
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1410
+
add_cookie jar delta_cookie;
1412
+
(* Get cookies should return both *)
1414
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
1416
+
Alcotest.(check int) "both cookies returned" 2 (List.length cookies);
1418
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
1419
+
Alcotest.(check (list string)) "cookie names" [ "delta"; "original" ] names
1421
+
let test_get_cookies_delta_takes_precedence () =
1422
+
Eio_mock.Backend.run @@ fun () ->
1423
+
let clock = Eio_mock.Clock.make () in
1424
+
Eio_mock.Clock.set_time clock 1000.0;
1426
+
let jar = create () in
1428
+
(* Add an original cookie *)
1430
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"orig_val"
1431
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1432
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1433
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1436
+
add_original jar original;
1438
+
(* Add a delta cookie with the same name/domain/path *)
1439
+
let delta_cookie =
1440
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"delta_val"
1441
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1442
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1443
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1446
+
add_cookie jar delta_cookie;
1448
+
(* Get cookies should return only the delta cookie *)
1450
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
1452
+
Alcotest.(check int) "only one cookie returned" 1 (List.length cookies);
1453
+
let cookie = List.hd cookies in
1454
+
Alcotest.(check string)
1455
+
"delta cookie value" "delta_val" (Cookeio.value cookie)
1457
+
let test_get_cookies_excludes_removal_cookies () =
1458
+
Eio_mock.Backend.run @@ fun () ->
1459
+
let clock = Eio_mock.Clock.make () in
1460
+
Eio_mock.Clock.set_time clock 1000.0;
1462
+
let jar = create () in
1464
+
(* Add an original cookie *)
1466
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1467
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1468
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1469
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1472
+
add_original jar original;
1475
+
remove jar ~clock original;
1477
+
(* Get cookies should return nothing *)
1479
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
1481
+
Alcotest.(check int) "no cookies returned" 0 (List.length cookies);
1483
+
(* But delta should have the removal cookie *)
1484
+
let delta = delta jar in
1485
+
Alcotest.(check int) "delta has removal cookie" 1 (List.length delta)
1487
+
let test_delta_returns_only_changed_cookies () =
1488
+
Eio_mock.Backend.run @@ fun () ->
1489
+
let clock = Eio_mock.Clock.make () in
1490
+
Eio_mock.Clock.set_time clock 1000.0;
1492
+
let jar = create () in
1494
+
(* Add original cookies *)
1496
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig1" ~value:"val1"
1497
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1498
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1499
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1502
+
add_original jar original1;
1505
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig2" ~value:"val2"
1506
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1507
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1508
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1511
+
add_original jar original2;
1513
+
(* Add a new delta cookie *)
1515
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"new_val"
1516
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1517
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1518
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1521
+
add_cookie jar new_cookie;
1523
+
(* Delta should only contain the new cookie *)
1524
+
let delta = delta jar in
1525
+
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
1526
+
let delta_cookie = List.hd delta in
1527
+
Alcotest.(check string) "delta cookie name" "new" (Cookeio.name delta_cookie)
1529
+
let test_removal_cookie_format () =
1530
+
Eio_mock.Backend.run @@ fun () ->
1531
+
let clock = Eio_mock.Clock.make () in
1532
+
Eio_mock.Clock.set_time clock 1000.0;
1534
+
let jar = create () in
1536
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1537
+
~secure:true ~http_only:true ?expires:None ~same_site:`Strict
1539
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1540
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1543
+
add_original jar cookie;
1545
+
(* Remove the cookie *)
1546
+
remove jar ~clock cookie;
1548
+
(* Get the removal cookie *)
1549
+
let delta = delta jar in
1550
+
let removal = List.hd delta in
1552
+
(* Check all properties *)
1553
+
Alcotest.(check string)
1554
+
"removal cookie has empty value" "" (Cookeio.value removal);
1555
+
Alcotest.(check (option int))
1556
+
"removal cookie Max-Age is 0" (Some 0)
1557
+
(Option.bind (Cookeio.max_age removal) Ptime.Span.to_int_s);
1559
+
(* Check expires is in the past *)
1560
+
let now = Ptime.of_float_s 1000.0 |> Option.get in
1561
+
match Cookeio.expires removal with
1562
+
| Some (`DateTime exp) ->
1563
+
Alcotest.(check bool)
1564
+
"expires is in the past" true
1565
+
(Ptime.compare exp now < 0)
1566
+
| _ -> Alcotest.fail "removal cookie should have DateTime expires"
1568
+
(* ============================================================================ *)
1569
+
(* Priority 2 Tests *)
1570
+
(* ============================================================================ *)
1572
+
(* Priority 2.1: Partitioned Cookies *)
1574
+
let test_partitioned_parsing env =
1575
+
let clock = Eio.Stdenv.clock env in
1578
+
of_set_cookie_header
1580
+
Ptime.of_float_s (Eio.Time.now clock)
1581
+
|> Option.value ~default:Ptime.epoch)
1582
+
~domain:"widget.com" ~path:"/" "id=123; Partitioned; Secure"
1585
+
Alcotest.(check bool) "partitioned flag" true (partitioned c);
1586
+
Alcotest.(check bool) "secure flag" true (secure c)
1587
+
| Error msg -> Alcotest.fail ("Should parse valid Partitioned cookie: " ^ msg)
1589
+
let test_partitioned_serialization env =
1590
+
let clock = Eio.Stdenv.clock env in
1592
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
1596
+
make ~domain:"widget.com" ~path:"/" ~name:"id" ~value:"123" ~secure:true
1597
+
~partitioned:true ~creation_time:now ~last_access:now ()
1600
+
let header = make_set_cookie_header cookie in
1601
+
let contains_substring s sub =
1603
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1605
+
with Not_found -> false
1607
+
let has_partitioned = contains_substring header "Partitioned" in
1608
+
let has_secure = contains_substring header "Secure" in
1609
+
Alcotest.(check bool) "contains Partitioned" true has_partitioned;
1610
+
Alcotest.(check bool) "contains Secure" true has_secure
1612
+
let test_partitioned_requires_secure env =
1613
+
let clock = Eio.Stdenv.clock env in
1615
+
(* Partitioned without Secure should be rejected *)
1617
+
of_set_cookie_header
1619
+
Ptime.of_float_s (Eio.Time.now clock)
1620
+
|> Option.value ~default:Ptime.epoch)
1621
+
~domain:"widget.com" ~path:"/" "id=123; Partitioned"
1623
+
| Error _ -> () (* Expected *)
1624
+
| Ok _ -> Alcotest.fail "Should reject Partitioned without Secure"
1626
+
(* Priority 2.2: Expiration Variants *)
1628
+
let test_expiration_variants env =
1629
+
let clock = Eio.Stdenv.clock env in
1631
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
1633
+
let make_base ~name ?expires () =
1634
+
make ~domain:"ex.com" ~path:"/" ~name ~value:"v" ?expires ~creation_time:now
1635
+
~last_access:now ()
1638
+
(* No expiration *)
1639
+
let c1 = make_base ~name:"no_expiry" () in
1640
+
Alcotest.(check (option expiration_testable))
1641
+
"no expiration" None (expires c1);
1643
+
(* Session cookie *)
1644
+
let c2 = make_base ~name:"session" ~expires:`Session () in
1645
+
Alcotest.(check (option expiration_testable))
1646
+
"session cookie" (Some `Session) (expires c2);
1648
+
(* Explicit expiration *)
1649
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
1650
+
let c3 = make_base ~name:"persistent" ~expires:(`DateTime future) () in
1651
+
match expires c3 with
1652
+
| Some (`DateTime t) when Ptime.equal t future -> ()
1653
+
| _ -> Alcotest.fail "Expected DateTime expiration"
1655
+
let test_parse_session_expiration env =
1656
+
let clock = Eio.Stdenv.clock env in
1658
+
(* Expires=0 should parse as Session *)
1660
+
of_set_cookie_header
1662
+
Ptime.of_float_s (Eio.Time.now clock)
1663
+
|> Option.value ~default:Ptime.epoch)
1664
+
~domain:"ex.com" ~path:"/" "id=123; Expires=0"
1667
+
Alcotest.(check (option expiration_testable))
1668
+
"expires=0 is session" (Some `Session) (expires c)
1669
+
| Error msg -> Alcotest.fail ("Should parse Expires=0: " ^ msg)
1671
+
let test_serialize_expiration_variants env =
1672
+
let clock = Eio.Stdenv.clock env in
1674
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
1676
+
let contains_substring s sub =
1678
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1680
+
with Not_found -> false
1683
+
(* Session cookie serialization *)
1685
+
make ~domain:"ex.com" ~path:"/" ~name:"s" ~value:"v" ~expires:`Session
1686
+
~creation_time:now ~last_access:now ()
1688
+
let h1 = make_set_cookie_header c1 in
1689
+
let has_expires = contains_substring h1 "Expires=" in
1690
+
Alcotest.(check bool) "session has Expires" true has_expires;
1692
+
(* DateTime serialization *)
1693
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
1695
+
make ~domain:"ex.com" ~path:"/" ~name:"p" ~value:"v"
1696
+
~expires:(`DateTime future) ~creation_time:now ~last_access:now ()
1698
+
let h2 = make_set_cookie_header c2 in
1699
+
let has_expires2 = contains_substring h2 "Expires=" in
1700
+
Alcotest.(check bool) "datetime has Expires" true has_expires2
1702
+
(* Priority 2.3: Value Trimming *)
1704
+
let test_quoted_cookie_values env =
1705
+
let clock = Eio.Stdenv.clock env in
1706
+
(* Test valid RFC 6265 cookie values:
1707
+
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
1708
+
Valid cases have either no quotes or properly paired DQUOTE wrapper *)
1711
+
("name=value", "value", "value"); (* No quotes *)
1712
+
("name=\"value\"", "\"value\"", "value"); (* Properly quoted *)
1713
+
("name=\"\"", "\"\"", ""); (* Empty quoted value *)
1718
+
(fun (input, expected_raw, expected_trimmed) ->
1720
+
of_set_cookie_header
1722
+
Ptime.of_float_s (Eio.Time.now clock)
1723
+
|> Option.value ~default:Ptime.epoch)
1724
+
~domain:"ex.com" ~path:"/" input
1727
+
Alcotest.(check string)
1728
+
(Printf.sprintf "raw value for %s" input)
1729
+
expected_raw (value c);
1730
+
Alcotest.(check string)
1731
+
(Printf.sprintf "trimmed value for %s" input)
1732
+
expected_trimmed (value_trimmed c)
1733
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ input ^ ": " ^ msg))
1736
+
(* Test invalid RFC 6265 cookie values are rejected *)
1737
+
let invalid_cases =
1739
+
"name=\"partial"; (* Opening quote without closing *)
1740
+
"name=\"val\"\""; (* Embedded quote *)
1741
+
"name=val\""; (* Trailing quote without opening *)
1748
+
of_set_cookie_header
1750
+
Ptime.of_float_s (Eio.Time.now clock)
1751
+
|> Option.value ~default:Ptime.epoch)
1752
+
~domain:"ex.com" ~path:"/" input
1754
+
| Error _ -> () (* Expected - invalid values are rejected *)
1757
+
(Printf.sprintf "Should reject invalid value: %s" input))
1760
+
let test_trimmed_value_not_used_for_equality env =
1761
+
let clock = Eio.Stdenv.clock env in
1764
+
of_set_cookie_header
1766
+
Ptime.of_float_s (Eio.Time.now clock)
1767
+
|> Option.value ~default:Ptime.epoch)
1768
+
~domain:"ex.com" ~path:"/" "name=\"value\""
1772
+
of_set_cookie_header
1774
+
Ptime.of_float_s (Eio.Time.now clock)
1775
+
|> Option.value ~default:Ptime.epoch)
1776
+
~domain:"ex.com" ~path:"/" "name=value"
1779
+
(* Different raw values *)
1780
+
Alcotest.(check bool)
1781
+
"different raw values" false
1782
+
(value c1 = value c2);
1783
+
(* Same trimmed values *)
1784
+
Alcotest.(check string)
1785
+
"same trimmed values" (value_trimmed c1) (value_trimmed c2)
1786
+
| Error msg -> Alcotest.fail ("Parse failed for unquoted: " ^ msg)
1788
+
| Error msg -> Alcotest.fail ("Parse failed for quoted: " ^ msg)
1790
+
(* Priority 2.4: Cookie Header Parsing *)
1792
+
let test_cookie_header_parsing_basic env =
1793
+
let clock = Eio.Stdenv.clock env in
1797
+
Ptime.of_float_s (Eio.Time.now clock)
1798
+
|> Option.value ~default:Ptime.epoch)
1799
+
~domain:"ex.com" ~path:"/" "session=abc123; theme=dark; lang=en"
1803
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
1805
+
Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
1807
+
let find name_val = List.find (fun c -> name c = name_val) cookies in
1808
+
Alcotest.(check string) "session value" "abc123" (value (find "session"));
1809
+
Alcotest.(check string) "theme value" "dark" (value (find "theme"));
1810
+
Alcotest.(check string) "lang value" "en" (value (find "lang"))
1812
+
let test_cookie_header_defaults env =
1813
+
let clock = Eio.Stdenv.clock env in
1818
+
Ptime.of_float_s (Eio.Time.now clock)
1819
+
|> Option.value ~default:Ptime.epoch)
1820
+
~domain:"example.com" ~path:"/app" "session=xyz"
1823
+
(* Domain and path from request context *)
1824
+
Alcotest.(check string) "domain from context" "example.com" (domain c);
1825
+
Alcotest.(check string) "path from context" "/app" (path c);
1827
+
(* Security flags default to false *)
1828
+
Alcotest.(check bool) "secure default" false (secure c);
1829
+
Alcotest.(check bool) "http_only default" false (http_only c);
1830
+
Alcotest.(check bool) "partitioned default" false (partitioned c);
1832
+
(* Optional attributes default to None *)
1833
+
Alcotest.(check (option expiration_testable))
1834
+
"no expiration" None (expires c);
1835
+
Alcotest.(check (option span_testable)) "no max_age" None (max_age c);
1836
+
Alcotest.(check (option same_site_testable))
1837
+
"no same_site" None (same_site c)
1838
+
| Ok _ -> Alcotest.fail "Should parse single cookie"
1839
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
1841
+
let test_cookie_header_edge_cases env =
1842
+
let clock = Eio.Stdenv.clock env in
1844
+
let test input expected_count description =
1848
+
Ptime.of_float_s (Eio.Time.now clock)
1849
+
|> Option.value ~default:Ptime.epoch)
1850
+
~domain:"ex.com" ~path:"/" input
1854
+
Alcotest.(check int) description expected_count (List.length cookies)
1856
+
Alcotest.fail (description ^ " failed: " ^ msg)
1859
+
test "" 0 "empty string";
1860
+
test ";;" 0 "only separators";
1861
+
test "a=1;;b=2" 2 "double separator";
1862
+
test " a=1 ; b=2 " 2 "excess whitespace";
1863
+
test " " 0 "only whitespace"
1865
+
let test_cookie_header_with_errors env =
1866
+
let clock = Eio.Stdenv.clock env in
1868
+
(* Invalid cookie (empty name) should cause entire parse to fail *)
1872
+
Ptime.of_float_s (Eio.Time.now clock)
1873
+
|> Option.value ~default:Ptime.epoch)
1874
+
~domain:"ex.com" ~path:"/" "valid=1;=noname;valid2=2"
1877
+
(* Error should have descriptive message about the invalid cookie *)
1878
+
let contains_substring s sub =
1880
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1882
+
with Not_found -> false
1886
+
let has_name = contains_substring msg "name" in
1887
+
let has_empty = contains_substring msg "empty" in
1888
+
Alcotest.(check bool)
1889
+
"error mentions name or empty" true (has_name || has_empty)
1890
+
| Ok _ -> Alcotest.fail "Expected error for empty cookie name"
1892
+
(* Max-Age and Expires Interaction *)
1894
+
let test_max_age_and_expires_both_present env =
1895
+
let clock = Eio.Stdenv.clock env in
1897
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
1899
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 7200) |> Option.get in
1901
+
(* Create cookie with both *)
1903
+
make ~domain:"ex.com" ~path:"/" ~name:"dual" ~value:"val"
1904
+
~max_age:(Ptime.Span.of_int_s 3600) ~expires:(`DateTime future)
1905
+
~creation_time:now ~last_access:now ()
1908
+
(* Both should be present *)
1909
+
begin match max_age cookie with
1910
+
| Some span -> begin
1911
+
match Ptime.Span.to_int_s span with
1913
+
Alcotest.(check int64) "max_age present" 3600L (Int64.of_int s)
1914
+
| None -> Alcotest.fail "max_age span could not be converted to int"
1916
+
| None -> Alcotest.fail "max_age should be present"
1919
+
begin match expires cookie with
1920
+
| Some (`DateTime t) when Ptime.equal t future -> ()
1921
+
| _ -> Alcotest.fail "expires should be present"
1924
+
(* Both should appear in serialization *)
1925
+
let header = make_set_cookie_header cookie in
1926
+
let contains_substring s sub =
1928
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1930
+
with Not_found -> false
1932
+
let has_max_age = contains_substring header "Max-Age=3600" in
1933
+
let has_expires = contains_substring header "Expires=" in
1934
+
Alcotest.(check bool) "contains Max-Age" true has_max_age;
1935
+
Alcotest.(check bool) "contains Expires" true has_expires
1937
+
let test_parse_max_age_and_expires env =
1938
+
let clock = Eio.Stdenv.clock env in
1940
+
(* Parse Set-Cookie with both attributes *)
1942
+
of_set_cookie_header
1944
+
Ptime.of_float_s (Eio.Time.now clock)
1945
+
|> Option.value ~default:Ptime.epoch)
1946
+
~domain:"ex.com" ~path:"/"
1947
+
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT"
1950
+
(* Both should be stored *)
1951
+
begin match max_age c with
1952
+
| Some span -> begin
1953
+
match Ptime.Span.to_int_s span with
1955
+
Alcotest.(check int64) "max_age parsed" 3600L (Int64.of_int s)
1956
+
| None -> Alcotest.fail "max_age span could not be converted to int"
1958
+
| None -> Alcotest.fail "max_age should be parsed"
1961
+
begin match expires c with
1962
+
| Some (`DateTime _) -> ()
1963
+
| _ -> Alcotest.fail "expires should be parsed"
1965
+
| Error msg -> Alcotest.fail ("Should parse cookie with both attributes: " ^ msg)
1967
+
(* ============================================================================ *)
1968
+
(* Host-Only Flag Tests (RFC 6265 Section 5.3) *)
1969
+
(* ============================================================================ *)
1971
+
let test_host_only_without_domain_attribute () =
1972
+
Eio_mock.Backend.run @@ fun () ->
1973
+
let clock = Eio_mock.Clock.make () in
1974
+
Eio_mock.Clock.set_time clock 1000.0;
1976
+
(* Cookie without Domain attribute should have host_only=true *)
1977
+
let header = "session=abc123; Secure; HttpOnly" in
1979
+
of_set_cookie_header
1981
+
Ptime.of_float_s (Eio.Time.now clock)
1982
+
|> Option.value ~default:Ptime.epoch)
1983
+
~domain:"example.com" ~path:"/" header
1985
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
1986
+
let cookie = Result.get_ok cookie_opt in
1987
+
Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
1988
+
Alcotest.(check string) "domain is request host" "example.com" (Cookeio.domain cookie)
1990
+
let test_host_only_with_domain_attribute () =
1991
+
Eio_mock.Backend.run @@ fun () ->
1992
+
let clock = Eio_mock.Clock.make () in
1993
+
Eio_mock.Clock.set_time clock 1000.0;
1995
+
(* Cookie with Domain attribute should have host_only=false *)
1996
+
let header = "session=abc123; Domain=example.com; Secure" in
1998
+
of_set_cookie_header
2000
+
Ptime.of_float_s (Eio.Time.now clock)
2001
+
|> Option.value ~default:Ptime.epoch)
2002
+
~domain:"example.com" ~path:"/" header
2004
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
2005
+
let cookie = Result.get_ok cookie_opt in
2006
+
Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
2007
+
Alcotest.(check string) "domain is attribute value" "example.com" (Cookeio.domain cookie)
2009
+
let test_host_only_with_dotted_domain_attribute () =
2010
+
Eio_mock.Backend.run @@ fun () ->
2011
+
let clock = Eio_mock.Clock.make () in
2012
+
Eio_mock.Clock.set_time clock 1000.0;
2014
+
(* Cookie with .domain should have host_only=false and normalized domain *)
2015
+
let header = "session=abc123; Domain=.example.com" in
2017
+
of_set_cookie_header
2019
+
Ptime.of_float_s (Eio.Time.now clock)
2020
+
|> Option.value ~default:Ptime.epoch)
2021
+
~domain:"example.com" ~path:"/" header
2023
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
2024
+
let cookie = Result.get_ok cookie_opt in
2025
+
Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
2026
+
Alcotest.(check string) "domain normalized" "example.com" (Cookeio.domain cookie)
2028
+
let test_host_only_domain_matching () =
2029
+
Eio_mock.Backend.run @@ fun () ->
2030
+
let clock = Eio_mock.Clock.make () in
2031
+
Eio_mock.Clock.set_time clock 1000.0;
2033
+
let jar = create () in
2035
+
(* Add a host-only cookie (no Domain attribute) *)
2036
+
let host_only_cookie =
2037
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"host_only" ~value:"val1"
2038
+
~secure:false ~http_only:false ~host_only:true
2039
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2040
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2042
+
add_cookie jar host_only_cookie;
2044
+
(* Add a domain cookie (with Domain attribute) *)
2045
+
let domain_cookie =
2046
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"val2"
2047
+
~secure:false ~http_only:false ~host_only:false
2048
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2049
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2051
+
add_cookie jar domain_cookie;
2053
+
(* Both cookies should match exact domain *)
2054
+
let cookies_exact =
2055
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2057
+
Alcotest.(check int) "both match exact domain" 2 (List.length cookies_exact);
2059
+
(* Only domain cookie should match subdomain *)
2061
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
2063
+
Alcotest.(check int) "only domain cookie matches subdomain" 1 (List.length cookies_sub);
2064
+
let sub_cookie = List.hd cookies_sub in
2065
+
Alcotest.(check string) "subdomain match is domain cookie" "domain" (Cookeio.name sub_cookie)
2067
+
let test_host_only_cookie_header_parsing () =
2068
+
Eio_mock.Backend.run @@ fun () ->
2069
+
let clock = Eio_mock.Clock.make () in
2070
+
Eio_mock.Clock.set_time clock 1000.0;
2072
+
(* Cookies from Cookie header should have host_only=true *)
2076
+
Ptime.of_float_s (Eio.Time.now clock)
2077
+
|> Option.value ~default:Ptime.epoch)
2078
+
~domain:"example.com" ~path:"/" "session=abc; theme=dark"
2081
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
2083
+
Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies);
2084
+
List.iter (fun c ->
2085
+
Alcotest.(check bool)
2086
+
("host_only is true for " ^ Cookeio.name c)
2087
+
true (Cookeio.host_only c)
2090
+
let test_host_only_mozilla_format_round_trip () =
2091
+
Eio_mock.Backend.run @@ fun () ->
2092
+
let clock = Eio_mock.Clock.make () in
2093
+
Eio_mock.Clock.set_time clock 1000.0;
2095
+
let jar = create () in
2097
+
(* Add host-only cookie *)
2099
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostonly" ~value:"v1"
2100
+
~secure:false ~http_only:false ~host_only:true
2101
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2102
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2104
+
add_cookie jar host_only;
2106
+
(* Add domain cookie *)
2107
+
let domain_cookie =
2108
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"v2"
2109
+
~secure:false ~http_only:false ~host_only:false
2110
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2111
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2113
+
add_cookie jar domain_cookie;
2115
+
(* Round trip through Mozilla format *)
2116
+
let mozilla = to_mozilla_format jar in
2117
+
let jar2 = from_mozilla_format ~clock mozilla in
2118
+
let cookies = get_all_cookies jar2 in
2120
+
Alcotest.(check int) "2 cookies after round trip" 2 (List.length cookies);
2122
+
let find name_val = List.find (fun c -> Cookeio.name c = name_val) cookies in
2123
+
Alcotest.(check bool) "hostonly preserved" true (Cookeio.host_only (find "hostonly"));
2124
+
Alcotest.(check bool) "domain preserved" false (Cookeio.host_only (find "domain"))
2126
+
(* ============================================================================ *)
2127
+
(* Path Matching Tests (RFC 6265 Section 5.1.4) *)
2128
+
(* ============================================================================ *)
2130
+
let test_path_matching_identical () =
2131
+
Eio_mock.Backend.run @@ fun () ->
2132
+
let clock = Eio_mock.Clock.make () in
2133
+
Eio_mock.Clock.set_time clock 1000.0;
2135
+
let jar = create () in
2137
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
2138
+
~secure:false ~http_only:false
2139
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2140
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2142
+
add_cookie jar cookie;
2144
+
(* Identical path should match *)
2146
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
2148
+
Alcotest.(check int) "identical path matches" 1 (List.length cookies)
2150
+
let test_path_matching_with_trailing_slash () =
2151
+
Eio_mock.Backend.run @@ fun () ->
2152
+
let clock = Eio_mock.Clock.make () in
2153
+
Eio_mock.Clock.set_time clock 1000.0;
2155
+
let jar = create () in
2157
+
Cookeio.make ~domain:"example.com" ~path:"/foo/" ~name:"test" ~value:"val"
2158
+
~secure:false ~http_only:false
2159
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2160
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2162
+
add_cookie jar cookie;
2164
+
(* Cookie path /foo/ should match /foo/bar *)
2166
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
2168
+
Alcotest.(check int) "/foo/ matches /foo/bar" 1 (List.length cookies);
2170
+
(* Cookie path /foo/ should match /foo/ *)
2172
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
2174
+
Alcotest.(check int) "/foo/ matches /foo/" 1 (List.length cookies2)
2176
+
let test_path_matching_prefix_with_slash () =
2177
+
Eio_mock.Backend.run @@ fun () ->
2178
+
let clock = Eio_mock.Clock.make () in
2179
+
Eio_mock.Clock.set_time clock 1000.0;
2181
+
let jar = create () in
2183
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
2184
+
~secure:false ~http_only:false
2185
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2186
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2188
+
add_cookie jar cookie;
2190
+
(* Cookie path /foo should match /foo/bar (next char is /) *)
2192
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
2194
+
Alcotest.(check int) "/foo matches /foo/bar" 1 (List.length cookies);
2196
+
(* Cookie path /foo should match /foo/ *)
2198
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
2200
+
Alcotest.(check int) "/foo matches /foo/" 1 (List.length cookies2)
2202
+
let test_path_matching_no_false_prefix () =
2203
+
Eio_mock.Backend.run @@ fun () ->
2204
+
let clock = Eio_mock.Clock.make () in
2205
+
Eio_mock.Clock.set_time clock 1000.0;
2207
+
let jar = create () in
2209
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
2210
+
~secure:false ~http_only:false
2211
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2212
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2214
+
add_cookie jar cookie;
2216
+
(* Cookie path /foo should NOT match /foobar (no / separator) *)
2218
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foobar" ~is_secure:false
2220
+
Alcotest.(check int) "/foo does NOT match /foobar" 0 (List.length cookies);
2222
+
(* Cookie path /foo should NOT match /foob *)
2224
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foob" ~is_secure:false
2226
+
Alcotest.(check int) "/foo does NOT match /foob" 0 (List.length cookies2)
2228
+
let test_path_matching_root () =
2229
+
Eio_mock.Backend.run @@ fun () ->
2230
+
let clock = Eio_mock.Clock.make () in
2231
+
Eio_mock.Clock.set_time clock 1000.0;
2233
+
let jar = create () in
2235
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"val"
2236
+
~secure:false ~http_only:false
2237
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2238
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2240
+
add_cookie jar cookie;
2242
+
(* Root path should match everything *)
2244
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2246
+
Alcotest.(check int) "/ matches /" 1 (List.length cookies1);
2249
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
2251
+
Alcotest.(check int) "/ matches /foo" 1 (List.length cookies2);
2254
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
2256
+
Alcotest.(check int) "/ matches /foo/bar/baz" 1 (List.length cookies3)
2258
+
let test_path_matching_no_match () =
2259
+
Eio_mock.Backend.run @@ fun () ->
2260
+
let clock = Eio_mock.Clock.make () in
2261
+
Eio_mock.Clock.set_time clock 1000.0;
2263
+
let jar = create () in
2265
+
Cookeio.make ~domain:"example.com" ~path:"/foo/bar" ~name:"test" ~value:"val"
2266
+
~secure:false ~http_only:false
2267
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2268
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2270
+
add_cookie jar cookie;
2272
+
(* Cookie path /foo/bar should NOT match /foo *)
2274
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
2276
+
Alcotest.(check int) "/foo/bar does NOT match /foo" 0 (List.length cookies);
2278
+
(* Cookie path /foo/bar should NOT match / *)
2280
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2282
+
Alcotest.(check int) "/foo/bar does NOT match /" 0 (List.length cookies2);
2284
+
(* Cookie path /foo/bar should NOT match /baz *)
2286
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/baz" ~is_secure:false
2288
+
Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
2290
+
(* ============================================================================ *)
2291
+
(* Cookie Ordering Tests (RFC 6265 Section 5.4, Step 2) *)
2292
+
(* ============================================================================ *)
2294
+
let test_cookie_ordering_by_path_length () =
2295
+
Eio_mock.Backend.run @@ fun () ->
2296
+
let clock = Eio_mock.Clock.make () in
2297
+
Eio_mock.Clock.set_time clock 1000.0;
2299
+
let jar = create () in
2301
+
(* Add cookies with different path lengths, but same creation time *)
2302
+
let cookie_short =
2303
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"short" ~value:"v1"
2304
+
~secure:false ~http_only:false
2305
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2306
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2308
+
let cookie_medium =
2309
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"medium" ~value:"v2"
2310
+
~secure:false ~http_only:false
2311
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2312
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2315
+
Cookeio.make ~domain:"example.com" ~path:"/foo/bar" ~name:"long" ~value:"v3"
2316
+
~secure:false ~http_only:false
2317
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2318
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2321
+
(* Add in random order *)
2322
+
add_cookie jar cookie_short;
2323
+
add_cookie jar cookie_long;
2324
+
add_cookie jar cookie_medium;
2326
+
(* Get cookies for path /foo/bar/baz - all three should match *)
2328
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
2331
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
2333
+
(* Verify order: longest path first *)
2334
+
let names = List.map Cookeio.name cookies in
2335
+
Alcotest.(check (list string))
2336
+
"cookies ordered by path length (longest first)"
2337
+
[ "long"; "medium"; "short" ]
2340
+
let test_cookie_ordering_by_creation_time () =
2341
+
Eio_mock.Backend.run @@ fun () ->
2342
+
let clock = Eio_mock.Clock.make () in
2343
+
Eio_mock.Clock.set_time clock 2000.0;
2345
+
let jar = create () in
2347
+
(* Add cookies with same path but different creation times *)
2349
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
2350
+
~secure:false ~http_only:false
2351
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
2352
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
2355
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"old" ~value:"v2"
2356
+
~secure:false ~http_only:false
2357
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2358
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2360
+
let cookie_middle =
2361
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"middle" ~value:"v3"
2362
+
~secure:false ~http_only:false
2363
+
~creation_time:(Ptime.of_float_s 1200.0 |> Option.get)
2364
+
~last_access:(Ptime.of_float_s 1200.0 |> Option.get) ()
2367
+
(* Add in random order *)
2368
+
add_cookie jar cookie_new;
2369
+
add_cookie jar cookie_old;
2370
+
add_cookie jar cookie_middle;
2373
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2376
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
2378
+
(* Verify order: earlier creation time first (for same path length) *)
2379
+
let names = List.map Cookeio.name cookies in
2380
+
Alcotest.(check (list string))
2381
+
"cookies ordered by creation time (earliest first)"
2382
+
[ "old"; "middle"; "new" ]
2385
+
let test_cookie_ordering_combined () =
2386
+
Eio_mock.Backend.run @@ fun () ->
2387
+
let clock = Eio_mock.Clock.make () in
2388
+
Eio_mock.Clock.set_time clock 2000.0;
2390
+
let jar = create () in
2392
+
(* Mix of different paths and creation times *)
2394
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"a" ~value:"v1"
2395
+
~secure:false ~http_only:false
2396
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
2397
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
2400
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"b" ~value:"v2"
2401
+
~secure:false ~http_only:false
2402
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2403
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2406
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"c" ~value:"v3"
2407
+
~secure:false ~http_only:false
2408
+
~creation_time:(Ptime.of_float_s 500.0 |> Option.get)
2409
+
~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
2412
+
add_cookie jar cookie_a;
2413
+
add_cookie jar cookie_c;
2414
+
add_cookie jar cookie_b;
2417
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
2420
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
2422
+
(* /foo cookies (length 4) should come before / cookie (length 1)
2423
+
Within /foo, earlier creation time (b=1000) should come before (a=1500) *)
2424
+
let names = List.map Cookeio.name cookies in
2425
+
Alcotest.(check (list string))
2426
+
"cookies ordered by path length then creation time"
2430
+
(* ============================================================================ *)
2431
+
(* Creation Time Preservation Tests (RFC 6265 Section 5.3, Step 11.3) *)
2432
+
(* ============================================================================ *)
2434
+
let test_creation_time_preserved_on_update () =
2435
+
Eio_mock.Backend.run @@ fun () ->
2436
+
let clock = Eio_mock.Clock.make () in
2437
+
Eio_mock.Clock.set_time clock 1000.0;
2439
+
let jar = create () in
2441
+
(* Add initial cookie with creation_time=500 *)
2442
+
let original_creation = Ptime.of_float_s 500.0 |> Option.get in
2444
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v1"
2445
+
~secure:false ~http_only:false
2446
+
~creation_time:original_creation
2447
+
~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
2449
+
add_cookie jar cookie_v1;
2451
+
(* Update the cookie with a new value (creation_time=1000) *)
2452
+
Eio_mock.Clock.set_time clock 1500.0;
2454
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v2"
2455
+
~secure:false ~http_only:false
2456
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
2457
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
2459
+
add_cookie jar cookie_v2;
2461
+
(* Get the cookie and verify creation_time was preserved *)
2463
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2465
+
Alcotest.(check int) "still one cookie" 1 (List.length cookies);
2467
+
let cookie = List.hd cookies in
2468
+
Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
2470
+
(* Creation time should be preserved from original cookie *)
2471
+
let creation_float =
2472
+
Ptime.to_float_s (Cookeio.creation_time cookie)
2474
+
Alcotest.(check (float 0.001))
2475
+
"creation_time preserved from original"
2476
+
500.0 creation_float
2478
+
let test_creation_time_preserved_add_original () =
2479
+
Eio_mock.Backend.run @@ fun () ->
2480
+
let clock = Eio_mock.Clock.make () in
2481
+
Eio_mock.Clock.set_time clock 1000.0;
2483
+
let jar = create () in
2485
+
(* Add initial original cookie *)
2486
+
let original_creation = Ptime.of_float_s 100.0 |> Option.get in
2488
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v1"
2489
+
~secure:false ~http_only:false
2490
+
~creation_time:original_creation
2491
+
~last_access:(Ptime.of_float_s 100.0 |> Option.get) ()
2493
+
add_original jar cookie_v1;
2495
+
(* Replace with new original cookie *)
2497
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v2"
2498
+
~secure:false ~http_only:false
2499
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2500
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2502
+
add_original jar cookie_v2;
2504
+
let cookies = get_all_cookies jar in
2505
+
Alcotest.(check int) "still one cookie" 1 (List.length cookies);
2507
+
let cookie = List.hd cookies in
2508
+
Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
2510
+
(* Creation time should be preserved *)
2511
+
let creation_float =
2512
+
Ptime.to_float_s (Cookeio.creation_time cookie)
2514
+
Alcotest.(check (float 0.001))
2515
+
"creation_time preserved in add_original"
2516
+
100.0 creation_float
2518
+
let test_creation_time_new_cookie () =
2519
+
Eio_mock.Backend.run @@ fun () ->
2520
+
let clock = Eio_mock.Clock.make () in
2521
+
Eio_mock.Clock.set_time clock 1000.0;
2523
+
let jar = create () in
2525
+
(* Add a new cookie (no existing cookie to preserve from) *)
2527
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
2528
+
~secure:false ~http_only:false
2529
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2530
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2532
+
add_cookie jar cookie;
2535
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2537
+
let cookie = List.hd cookies in
2539
+
(* New cookie should keep its own creation time *)
2540
+
let creation_float =
2541
+
Ptime.to_float_s (Cookeio.creation_time cookie)
2543
+
Alcotest.(check (float 0.001))
2544
+
"new cookie keeps its creation_time"
2545
+
1000.0 creation_float
2547
+
(* ============================================================================ *)
2548
+
(* IP Address Domain Matching Tests (RFC 6265 Section 5.1.3) *)
2549
+
(* ============================================================================ *)
2551
+
let test_ipv4_exact_match () =
2552
+
Eio_mock.Backend.run @@ fun () ->
2553
+
let clock = Eio_mock.Clock.make () in
2554
+
Eio_mock.Clock.set_time clock 1000.0;
2556
+
let jar = create () in
2558
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val"
2559
+
~secure:false ~http_only:false ~host_only:false
2560
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2561
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2563
+
add_cookie jar cookie;
2565
+
(* IPv4 cookie should match exact IP *)
2567
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
2569
+
Alcotest.(check int) "IPv4 exact match" 1 (List.length cookies)
2571
+
let test_ipv4_no_suffix_match () =
2572
+
Eio_mock.Backend.run @@ fun () ->
2573
+
let clock = Eio_mock.Clock.make () in
2574
+
Eio_mock.Clock.set_time clock 1000.0;
2576
+
let jar = create () in
2577
+
(* Cookie for 168.1.1 - this should NOT match requests to 192.168.1.1
2578
+
even though "192.168.1.1" ends with ".168.1.1" *)
2580
+
Cookeio.make ~domain:"168.1.1" ~path:"/" ~name:"test" ~value:"val"
2581
+
~secure:false ~http_only:false ~host_only:false
2582
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2583
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2585
+
add_cookie jar cookie;
2587
+
(* Should NOT match - IP addresses don't do suffix matching *)
2589
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
2591
+
Alcotest.(check int) "IPv4 no suffix match" 0 (List.length cookies)
2593
+
let test_ipv4_different_ip () =
2594
+
Eio_mock.Backend.run @@ fun () ->
2595
+
let clock = Eio_mock.Clock.make () in
2596
+
Eio_mock.Clock.set_time clock 1000.0;
2598
+
let jar = create () in
2600
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val"
2601
+
~secure:false ~http_only:false ~host_only:false
2602
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2603
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2605
+
add_cookie jar cookie;
2607
+
(* Different IP should not match *)
2609
+
get_cookies jar ~clock ~domain:"192.168.1.2" ~path:"/" ~is_secure:false
2611
+
Alcotest.(check int) "different IPv4 no match" 0 (List.length cookies)
2613
+
let test_ipv6_exact_match () =
2614
+
Eio_mock.Backend.run @@ fun () ->
2615
+
let clock = Eio_mock.Clock.make () in
2616
+
Eio_mock.Clock.set_time clock 1000.0;
2618
+
let jar = create () in
2620
+
Cookeio.make ~domain:"::1" ~path:"/" ~name:"test" ~value:"val"
2621
+
~secure:false ~http_only:false ~host_only:false
2622
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2623
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2625
+
add_cookie jar cookie;
2627
+
(* IPv6 loopback should match exactly *)
2629
+
get_cookies jar ~clock ~domain:"::1" ~path:"/" ~is_secure:false
2631
+
Alcotest.(check int) "IPv6 exact match" 1 (List.length cookies)
2633
+
let test_ipv6_full_format () =
2634
+
Eio_mock.Backend.run @@ fun () ->
2635
+
let clock = Eio_mock.Clock.make () in
2636
+
Eio_mock.Clock.set_time clock 1000.0;
2638
+
let jar = create () in
2640
+
Cookeio.make ~domain:"2001:db8::1" ~path:"/" ~name:"test" ~value:"val"
2641
+
~secure:false ~http_only:false ~host_only:false
2642
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2643
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2645
+
add_cookie jar cookie;
2647
+
(* IPv6 should match exactly *)
2649
+
get_cookies jar ~clock ~domain:"2001:db8::1" ~path:"/" ~is_secure:false
2651
+
Alcotest.(check int) "IPv6 full format match" 1 (List.length cookies);
2653
+
(* Different IPv6 should not match *)
2655
+
get_cookies jar ~clock ~domain:"2001:db8::2" ~path:"/" ~is_secure:false
2657
+
Alcotest.(check int) "different IPv6 no match" 0 (List.length cookies2)
2659
+
let test_ip_vs_hostname () =
2660
+
Eio_mock.Backend.run @@ fun () ->
2661
+
let clock = Eio_mock.Clock.make () in
2662
+
Eio_mock.Clock.set_time clock 1000.0;
2664
+
let jar = create () in
2666
+
(* Add a hostname cookie with host_only=false (domain cookie) *)
2667
+
let hostname_cookie =
2668
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostname" ~value:"h1"
2669
+
~secure:false ~http_only:false ~host_only:false
2670
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2671
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2673
+
add_cookie jar hostname_cookie;
2675
+
(* Add an IP cookie with host_only=false *)
2677
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"ip" ~value:"i1"
2678
+
~secure:false ~http_only:false ~host_only:false
2679
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2680
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2682
+
add_cookie jar ip_cookie;
2684
+
(* Hostname request should match hostname cookie and subdomains *)
2686
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2688
+
Alcotest.(check int) "hostname matches hostname cookie" 1 (List.length cookies1);
2691
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
2693
+
Alcotest.(check int) "subdomain matches hostname cookie" 1 (List.length cookies2);
2695
+
(* IP request should only match IP cookie exactly *)
2697
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
2699
+
Alcotest.(check int) "IP matches IP cookie" 1 (List.length cookies3);
2700
+
Alcotest.(check string) "IP cookie is returned" "ip" (Cookeio.name (List.hd cookies3))
2702
+
(* ============================================================================ *)
2703
+
(* RFC 6265 Validation Tests *)
2704
+
(* ============================================================================ *)
2706
+
let test_validate_cookie_name_valid () =
2707
+
(* Valid token characters per RFC 2616 *)
2708
+
let valid_names = ["session"; "SID"; "my-cookie"; "COOKIE_123"; "abc.def"] in
2709
+
List.iter (fun name ->
2710
+
match Cookeio.Validate.cookie_name name with
2713
+
Alcotest.fail (Printf.sprintf "Name %S should be valid: %s" name msg))
2716
+
let test_validate_cookie_name_invalid () =
2717
+
(* Invalid: control chars, separators, spaces *)
2718
+
let invalid_names =
2721
+
("my cookie", "space");
2722
+
("cookie=value", "equals");
2723
+
("my;cookie", "semicolon");
2724
+
("name\t", "tab");
2725
+
("(cookie)", "parens");
2726
+
("name,val", "comma");
2729
+
List.iter (fun (name, reason) ->
2730
+
match Cookeio.Validate.cookie_name name with
2731
+
| Error _ -> () (* Expected *)
2734
+
(Printf.sprintf "Name %S (%s) should be invalid" name reason))
2737
+
let test_validate_cookie_value_valid () =
2738
+
(* Valid cookie-octets or quoted values *)
2739
+
let valid_values = ["abc123"; "value!#$%&'()*+-./"; "\"quoted\""; ""] in
2740
+
List.iter (fun value ->
2741
+
match Cookeio.Validate.cookie_value value with
2744
+
Alcotest.fail (Printf.sprintf "Value %S should be valid: %s" value msg))
2747
+
let test_validate_cookie_value_invalid () =
2748
+
(* Invalid: space, comma, semicolon, backslash, unmatched quotes *)
2749
+
let invalid_values =
2751
+
("with space", "space");
2752
+
("with,comma", "comma");
2753
+
("with;semi", "semicolon");
2754
+
("back\\slash", "backslash");
2755
+
("\"unmatched", "unmatched opening quote");
2756
+
("unmatched\"", "unmatched closing quote");
2759
+
List.iter (fun (value, reason) ->
2760
+
match Cookeio.Validate.cookie_value value with
2761
+
| Error _ -> () (* Expected *)
2764
+
(Printf.sprintf "Value %S (%s) should be invalid" value reason))
2767
+
let test_validate_domain_valid () =
2768
+
(* Valid domain names and IP addresses *)
2769
+
let valid_domains =
2770
+
["example.com"; "sub.example.com"; ".example.com"; "192.168.1.1"; "::1"]
2772
+
List.iter (fun domain ->
2773
+
match Cookeio.Validate.domain_value domain with
2776
+
Alcotest.fail (Printf.sprintf "Domain %S should be valid: %s" domain msg))
2779
+
let test_validate_domain_invalid () =
2780
+
(* Invalid domain names - only test cases that domain-name library rejects.
2781
+
Note: domain-name library has specific rules that may differ from what
2782
+
we might expect from the RFC. *)
2783
+
let invalid_domains =
2786
+
(* Note: "-invalid.com" and "invalid-.com" are valid per domain-name library *)
2789
+
List.iter (fun (domain, reason) ->
2790
+
match Cookeio.Validate.domain_value domain with
2791
+
| Error _ -> () (* Expected *)
2794
+
(Printf.sprintf "Domain %S (%s) should be invalid" domain reason))
2797
+
let test_validate_path_valid () =
2798
+
let valid_paths = ["/"; "/path"; "/path/to/resource"; "/path?query"] in
2799
+
List.iter (fun path ->
2800
+
match Cookeio.Validate.path_value path with
2803
+
Alcotest.fail (Printf.sprintf "Path %S should be valid: %s" path msg))
2806
+
let test_validate_path_invalid () =
2807
+
let invalid_paths =
2809
+
("/path;bad", "semicolon");
2810
+
("/path\x00bad", "control char");
2813
+
List.iter (fun (path, reason) ->
2814
+
match Cookeio.Validate.path_value path with
2815
+
| Error _ -> () (* Expected *)
2818
+
(Printf.sprintf "Path %S (%s) should be invalid" path reason))
2821
+
let test_duplicate_cookie_detection () =
2822
+
Eio_mock.Backend.run @@ fun () ->
2823
+
let clock = Eio_mock.Clock.make () in
2824
+
Eio_mock.Clock.set_time clock 1000.0;
2826
+
(* Duplicate cookie names should be rejected *)
2830
+
Ptime.of_float_s (Eio.Time.now clock)
2831
+
|> Option.value ~default:Ptime.epoch)
2832
+
~domain:"example.com" ~path:"/" "session=abc; theme=dark; session=xyz"
2836
+
(* Should mention duplicate *)
2837
+
let contains_dup = String.lowercase_ascii msg |> fun s ->
2838
+
try let _ = Str.search_forward (Str.regexp_string "duplicate") s 0 in true
2839
+
with Not_found -> false
2841
+
Alcotest.(check bool) "error mentions duplicate" true contains_dup
2842
+
| Ok _ -> Alcotest.fail "Should reject duplicate cookie names"
2844
+
let test_validation_error_messages () =
2845
+
Eio_mock.Backend.run @@ fun () ->
2846
+
let clock = Eio_mock.Clock.make () in
2847
+
Eio_mock.Clock.set_time clock 1000.0;
2849
+
(* Test that error messages are descriptive *)
2852
+
("=noname", "Cookie name is empty");
2853
+
("bad cookie=value", "invalid characters");
2854
+
("name=val ue", "invalid characters");
2857
+
List.iter (fun (header, expected_substring) ->
2859
+
of_set_cookie_header
2861
+
Ptime.of_float_s (Eio.Time.now clock)
2862
+
|> Option.value ~default:Ptime.epoch)
2863
+
~domain:"example.com" ~path:"/" header
2866
+
let has_substring =
2868
+
let _ = Str.search_forward
2869
+
(Str.regexp_string expected_substring) msg 0 in
2871
+
with Not_found -> false
2873
+
Alcotest.(check bool)
2874
+
(Printf.sprintf "error for %S mentions %S" header expected_substring)
2875
+
true has_substring
2877
+
Alcotest.fail (Printf.sprintf "Should reject %S" header))
2880
+
(* ============================================================================ *)
2881
+
(* Public Suffix Validation Tests (RFC 6265 Section 5.3, Step 5) *)
2882
+
(* ============================================================================ *)
2884
+
let test_public_suffix_rejection () =
2885
+
Eio_mock.Backend.run @@ fun () ->
2886
+
let clock = Eio_mock.Clock.make () in
2887
+
Eio_mock.Clock.set_time clock 1000.0;
2889
+
(* Setting a cookie for a public suffix (TLD) should be rejected *)
2892
+
(* (request_domain, cookie_domain, description) *)
2893
+
("www.example.com", "com", "TLD .com");
2894
+
("www.example.co.uk", "co.uk", "ccTLD .co.uk");
2895
+
("foo.bar.github.io", "github.io", "private domain github.io");
2900
+
(fun (request_domain, cookie_domain, description) ->
2901
+
let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
2903
+
of_set_cookie_header
2905
+
Ptime.of_float_s (Eio.Time.now clock)
2906
+
|> Option.value ~default:Ptime.epoch)
2907
+
~domain:request_domain ~path:"/" header
2911
+
(* Should mention public suffix *)
2913
+
String.lowercase_ascii msg |> fun s ->
2915
+
let _ = Str.search_forward (Str.regexp_string "public suffix") s 0 in
2917
+
with Not_found -> false
2919
+
Alcotest.(check bool)
2920
+
(Printf.sprintf "%s: error mentions public suffix" description)
2924
+
(Printf.sprintf "Should reject cookie for %s" description))
2927
+
let test_public_suffix_allowed_when_exact_match () =
2928
+
Eio_mock.Backend.run @@ fun () ->
2929
+
let clock = Eio_mock.Clock.make () in
2930
+
Eio_mock.Clock.set_time clock 1000.0;
2932
+
(* If request host exactly matches the public suffix domain, allow it.
2933
+
This is rare but possible for private domains like blogspot.com *)
2934
+
let header = "session=abc; Domain=.blogspot.com" in
2936
+
of_set_cookie_header
2938
+
Ptime.of_float_s (Eio.Time.now clock)
2939
+
|> Option.value ~default:Ptime.epoch)
2940
+
~domain:"blogspot.com" ~path:"/" header
2942
+
Alcotest.(check bool)
2943
+
"exact match allows public suffix" true
2944
+
(Result.is_ok result)
2946
+
let test_non_public_suffix_allowed () =
2947
+
Eio_mock.Backend.run @@ fun () ->
2948
+
let clock = Eio_mock.Clock.make () in
2949
+
Eio_mock.Clock.set_time clock 1000.0;
2951
+
(* Normal domain (not a public suffix) should be allowed *)
2954
+
("www.example.com", "example.com", "registrable domain");
2955
+
("sub.example.com", "example.com", "parent of subdomain");
2956
+
("www.example.co.uk", "example.co.uk", "registrable domain under ccTLD");
2961
+
(fun (request_domain, cookie_domain, description) ->
2962
+
let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
2964
+
of_set_cookie_header
2966
+
Ptime.of_float_s (Eio.Time.now clock)
2967
+
|> Option.value ~default:Ptime.epoch)
2968
+
~domain:request_domain ~path:"/" header
2972
+
Alcotest.(check string)
2973
+
(Printf.sprintf "%s: domain correct" description)
2974
+
cookie_domain (Cookeio.domain cookie)
2977
+
(Printf.sprintf "%s should be allowed: %s" description msg))
2980
+
let test_public_suffix_no_domain_attribute () =
2981
+
Eio_mock.Backend.run @@ fun () ->
2982
+
let clock = Eio_mock.Clock.make () in
2983
+
Eio_mock.Clock.set_time clock 1000.0;
2985
+
(* Cookie without Domain attribute should always be allowed (host-only) *)
2986
+
let header = "session=abc; Secure; HttpOnly" in
2988
+
of_set_cookie_header
2990
+
Ptime.of_float_s (Eio.Time.now clock)
2991
+
|> Option.value ~default:Ptime.epoch)
2992
+
~domain:"www.example.com" ~path:"/" header
2996
+
Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
2997
+
Alcotest.(check string)
2998
+
"domain is request domain" "www.example.com"
2999
+
(Cookeio.domain cookie)
3000
+
| Error msg -> Alcotest.fail ("Should allow host-only cookie: " ^ msg)
3002
+
let test_public_suffix_ip_address_bypass () =
3003
+
Eio_mock.Backend.run @@ fun () ->
3004
+
let clock = Eio_mock.Clock.make () in
3005
+
Eio_mock.Clock.set_time clock 1000.0;
3007
+
(* IP addresses should bypass PSL check *)
3008
+
let header = "session=abc; Domain=192.168.1.1" in
3010
+
of_set_cookie_header
3012
+
Ptime.of_float_s (Eio.Time.now clock)
3013
+
|> Option.value ~default:Ptime.epoch)
3014
+
~domain:"192.168.1.1" ~path:"/" header
3016
+
Alcotest.(check bool)
3017
+
"IP address bypasses PSL" true
3018
+
(Result.is_ok result)
3020
+
let test_public_suffix_case_insensitive () =
3021
+
Eio_mock.Backend.run @@ fun () ->
3022
+
let clock = Eio_mock.Clock.make () in
3023
+
Eio_mock.Clock.set_time clock 1000.0;
3025
+
(* Public suffix check should be case-insensitive *)
3026
+
let header = "session=abc; Domain=.COM" in
3028
+
of_set_cookie_header
3030
+
Ptime.of_float_s (Eio.Time.now clock)
3031
+
|> Option.value ~default:Ptime.epoch)
3032
+
~domain:"www.example.COM" ~path:"/" header
3034
+
Alcotest.(check bool)
3035
+
"uppercase TLD still rejected" true
3036
+
(Result.is_error result)
Eio_main.run @@ fun env ->
···
test_cookie_matching env);
257
-
[ test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env) ]
3059
+
test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env);
3061
+
( "time_handling",
3063
+
test_case "Cookie expiry with mock clock" `Quick
3064
+
test_cookie_expiry_with_mock_clock;
3065
+
test_case "get_cookies filters expired cookies" `Quick
3066
+
test_get_cookies_filters_expired;
3067
+
test_case "Max-Age parsing with mock clock" `Quick
3068
+
test_max_age_parsing_with_mock_clock;
3069
+
test_case "Last access time with mock clock" `Quick
3070
+
test_last_access_time_with_mock_clock;
3071
+
test_case "Parse Set-Cookie with Expires" `Quick
3072
+
test_of_set_cookie_header_with_expires;
3073
+
test_case "SameSite=None validation" `Quick
3074
+
test_samesite_none_validation;
3076
+
( "domain_normalization",
3078
+
test_case "Domain normalization" `Quick test_domain_normalization;
3079
+
test_case "Domain matching with normalized domains" `Quick
3080
+
test_domain_matching;
3082
+
( "max_age_tracking",
3084
+
test_case "Max-Age stored separately from Expires" `Quick
3085
+
test_max_age_stored_separately;
3086
+
test_case "Negative Max-Age becomes 0" `Quick
3087
+
test_max_age_negative_becomes_zero;
3088
+
test_case "make_set_cookie_header includes Max-Age" `Quick
3089
+
test_make_set_cookie_header_includes_max_age;
3090
+
test_case "Max-Age round-trip parsing" `Quick test_max_age_round_trip;
3092
+
( "delta_tracking",
3094
+
test_case "add_original doesn't affect delta" `Quick
3095
+
test_add_original_not_in_delta;
3096
+
test_case "add_cookie appears in delta" `Quick
3097
+
test_add_cookie_appears_in_delta;
3098
+
test_case "remove original creates removal cookie" `Quick
3099
+
test_remove_original_creates_removal_cookie;
3100
+
test_case "remove delta cookie just removes it" `Quick
3101
+
test_remove_delta_cookie_removes_it;
3102
+
test_case "get_cookies combines original and delta" `Quick
3103
+
test_get_cookies_combines_original_and_delta;
3104
+
test_case "get_cookies delta takes precedence" `Quick
3105
+
test_get_cookies_delta_takes_precedence;
3106
+
test_case "get_cookies excludes removal cookies" `Quick
3107
+
test_get_cookies_excludes_removal_cookies;
3108
+
test_case "delta returns only changed cookies" `Quick
3109
+
test_delta_returns_only_changed_cookies;
3110
+
test_case "removal cookie format" `Quick test_removal_cookie_format;
3112
+
( "http_date_parsing",
3114
+
test_case "HTTP date FMT1 (RFC 1123)" `Quick test_http_date_fmt1;
3115
+
test_case "HTTP date FMT2 (RFC 850)" `Quick test_http_date_fmt2;
3116
+
test_case "HTTP date FMT3 (asctime)" `Quick test_http_date_fmt3;
3117
+
test_case "HTTP date FMT4 (variant)" `Quick test_http_date_fmt4;
3118
+
test_case "Abbreviated year 69-99 becomes 1900+" `Quick
3119
+
test_abbreviated_year_69_to_99;
3120
+
test_case "Abbreviated year 0-68 becomes 2000+" `Quick
3121
+
test_abbreviated_year_0_to_68;
3122
+
test_case "RFC 3339 backward compatibility" `Quick
3123
+
test_rfc3339_still_works;
3124
+
test_case "Invalid date format logs warning" `Quick
3125
+
test_invalid_date_format_logs_warning;
3126
+
test_case "Case-insensitive month parsing" `Quick
3127
+
test_case_insensitive_month_parsing;
3128
+
test_case "Case-insensitive GMT parsing" `Quick
3129
+
test_case_insensitive_gmt_parsing;
3133
+
test_case "parse partitioned cookie" `Quick (fun () ->
3134
+
test_partitioned_parsing env);
3135
+
test_case "serialize partitioned cookie" `Quick (fun () ->
3136
+
test_partitioned_serialization env);
3137
+
test_case "partitioned requires secure" `Quick (fun () ->
3138
+
test_partitioned_requires_secure env);
3142
+
test_case "expiration variants" `Quick (fun () ->
3143
+
test_expiration_variants env);
3144
+
test_case "parse session expiration" `Quick (fun () ->
3145
+
test_parse_session_expiration env);
3146
+
test_case "serialize expiration variants" `Quick (fun () ->
3147
+
test_serialize_expiration_variants env);
3149
+
( "value_trimming",
3151
+
test_case "quoted values" `Quick (fun () ->
3152
+
test_quoted_cookie_values env);
3153
+
test_case "trimmed not used for equality" `Quick (fun () ->
3154
+
test_trimmed_value_not_used_for_equality env);
3156
+
( "cookie_header",
3158
+
test_case "parse basic" `Quick (fun () ->
3159
+
test_cookie_header_parsing_basic env);
3160
+
test_case "default values" `Quick (fun () ->
3161
+
test_cookie_header_defaults env);
3162
+
test_case "edge cases" `Quick (fun () ->
3163
+
test_cookie_header_edge_cases env);
3164
+
test_case "multiple with errors" `Quick (fun () ->
3165
+
test_cookie_header_with_errors env);
3167
+
( "max_age_expires_interaction",
3169
+
test_case "both present" `Quick (fun () ->
3170
+
test_max_age_and_expires_both_present env);
3171
+
test_case "parse both" `Quick (fun () ->
3172
+
test_parse_max_age_and_expires env);
3174
+
( "host_only_flag",
3176
+
test_case "host_only without Domain attribute" `Quick
3177
+
test_host_only_without_domain_attribute;
3178
+
test_case "host_only with Domain attribute" `Quick
3179
+
test_host_only_with_domain_attribute;
3180
+
test_case "host_only with dotted Domain attribute" `Quick
3181
+
test_host_only_with_dotted_domain_attribute;
3182
+
test_case "host_only domain matching" `Quick
3183
+
test_host_only_domain_matching;
3184
+
test_case "host_only Cookie header parsing" `Quick
3185
+
test_host_only_cookie_header_parsing;
3186
+
test_case "host_only Mozilla format round trip" `Quick
3187
+
test_host_only_mozilla_format_round_trip;
3189
+
( "path_matching",
3191
+
test_case "identical path" `Quick test_path_matching_identical;
3192
+
test_case "path with trailing slash" `Quick
3193
+
test_path_matching_with_trailing_slash;
3194
+
test_case "prefix with slash separator" `Quick
3195
+
test_path_matching_prefix_with_slash;
3196
+
test_case "no false prefix match" `Quick
3197
+
test_path_matching_no_false_prefix;
3198
+
test_case "root path matches all" `Quick test_path_matching_root;
3199
+
test_case "path no match" `Quick test_path_matching_no_match;
3201
+
( "ip_address_matching",
3203
+
test_case "IPv4 exact match" `Quick test_ipv4_exact_match;
3204
+
test_case "IPv4 no suffix match" `Quick test_ipv4_no_suffix_match;
3205
+
test_case "IPv4 different IP no match" `Quick test_ipv4_different_ip;
3206
+
test_case "IPv6 exact match" `Quick test_ipv6_exact_match;
3207
+
test_case "IPv6 full format" `Quick test_ipv6_full_format;
3208
+
test_case "IP vs hostname behavior" `Quick test_ip_vs_hostname;
3210
+
( "rfc6265_validation",
3212
+
test_case "valid cookie names" `Quick test_validate_cookie_name_valid;
3213
+
test_case "invalid cookie names" `Quick test_validate_cookie_name_invalid;
3214
+
test_case "valid cookie values" `Quick test_validate_cookie_value_valid;
3215
+
test_case "invalid cookie values" `Quick test_validate_cookie_value_invalid;
3216
+
test_case "valid domain values" `Quick test_validate_domain_valid;
3217
+
test_case "invalid domain values" `Quick test_validate_domain_invalid;
3218
+
test_case "valid path values" `Quick test_validate_path_valid;
3219
+
test_case "invalid path values" `Quick test_validate_path_invalid;
3220
+
test_case "duplicate cookie detection" `Quick test_duplicate_cookie_detection;
3221
+
test_case "validation error messages" `Quick test_validation_error_messages;
3223
+
( "cookie_ordering",
3225
+
test_case "ordering by path length" `Quick
3226
+
test_cookie_ordering_by_path_length;
3227
+
test_case "ordering by creation time" `Quick
3228
+
test_cookie_ordering_by_creation_time;
3229
+
test_case "ordering combined" `Quick test_cookie_ordering_combined;
3231
+
( "creation_time_preservation",
3233
+
test_case "preserved on update" `Quick
3234
+
test_creation_time_preserved_on_update;
3235
+
test_case "preserved in add_original" `Quick
3236
+
test_creation_time_preserved_add_original;
3237
+
test_case "new cookie keeps time" `Quick test_creation_time_new_cookie;
3239
+
( "public_suffix_validation",
3241
+
test_case "reject public suffix domains" `Quick
3242
+
test_public_suffix_rejection;
3243
+
test_case "allow exact match on public suffix" `Quick
3244
+
test_public_suffix_allowed_when_exact_match;
3245
+
test_case "allow non-public-suffix domains" `Quick
3246
+
test_non_public_suffix_allowed;
3247
+
test_case "no Domain attribute bypasses PSL" `Quick
3248
+
test_public_suffix_no_domain_attribute;
3249
+
test_case "IP address bypasses PSL" `Quick
3250
+
test_public_suffix_ip_address_bypass;
3251
+
test_case "case insensitive check" `Quick
3252
+
test_public_suffix_case_insensitive;