···
3
+
(* Testable helpers for Priority 2 types *)
4
+
let expiration_testable : Cookeio.Expiration.t Alcotest.testable =
5
+
Alcotest.testable Cookeio.Expiration.pp Cookeio.Expiration.equal
7
+
let span_testable : Ptime.Span.t Alcotest.testable =
8
+
Alcotest.testable Ptime.Span.pp Ptime.Span.equal
10
+
let same_site_testable : Cookeio.SameSite.t Alcotest.testable =
11
+
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; max_age=%a; same_site=%a }"
18
+
partitioned=%b; expires=%a; max_age=%a; same_site=%a }"
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c)
10
-
(Cookeio.secure c) (Cookeio.http_only c)
11
-
(Format.pp_print_option Ptime.pp)
20
+
(Cookeio.secure c) (Cookeio.http_only c) (Cookeio.partitioned c)
21
+
(Format.pp_print_option
24
+
| `Session -> Format.pp_print_string ppf "Session"
25
+
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t))
(Format.pp_print_option Ptime.Span.pp)
15
-
(Format.pp_print_option (fun ppf -> function
16
-
| `Strict -> Format.pp_print_string ppf "Strict"
17
-
| `Lax -> Format.pp_print_string ppf "Lax"
18
-
| `None -> Format.pp_print_string ppf "None"))
29
+
(Format.pp_print_option
30
+
(fun ppf -> function
31
+
| `Strict -> Format.pp_print_string ppf "Strict"
32
+
| `Lax -> Format.pp_print_string ppf "Lax"
33
+
| `None -> Format.pp_print_string ppf "None"))
36
+
let expires_equal e1 e2 =
38
+
| None, None -> true
39
+
| Some `Session, Some `Session -> true
40
+
| Some (`DateTime t1), Some (`DateTime t2) -> Ptime.equal t1 t2
Cookeio.name c1 = Cookeio.name c2
&& Cookeio.value c1 = Cookeio.value c2
&& Cookeio.domain c1 = Cookeio.domain c2
&& Cookeio.path c1 = Cookeio.path c2
&& Cookeio.secure c1 = Cookeio.secure c2
&& Cookeio.http_only c1 = Cookeio.http_only c2
27
-
&& Option.equal Ptime.equal (Cookeio.expires c1) (Cookeio.expires c2)
49
+
&& Cookeio.partitioned c1 = Cookeio.partitioned c2
50
+
&& expires_equal (Cookeio.expires c1) (Cookeio.expires c2)
&& Option.equal Ptime.Span.equal (Cookeio.max_age c1) (Cookeio.max_age c2)
&& Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
···
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);
66
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
89
+
Alcotest.(check (option expiration_testable))
"cookie-1 expires" None (Cookeio.expires cookie1);
···
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);
89
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
112
+
Alcotest.(check (option expiration_testable))
"cookie-2 expires" None (Cookeio.expires cookie2);
(* Test cookie-3: non-session cookie with expiry *)
···
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);
102
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
103
-
"cookie-3 expires" expected_expiry (Cookeio.expires cookie3);
125
+
begin match expected_expiry with
127
+
Alcotest.(check (option expiration_testable))
128
+
"cookie-3 expires" (Some (`DateTime t)) (Cookeio.expires cookie3)
129
+
| None -> Alcotest.fail "Expected expiry time for cookie-3"
(* Test cookie-4: another non-session cookie *)
let cookie4 = find_cookie "cookie-4" in
···
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);
114
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
115
-
"cookie-4 expires" expected_expiry (Cookeio.expires cookie4);
141
+
begin match expected_expiry with
143
+
Alcotest.(check (option expiration_testable))
144
+
"cookie-4 expires" (Some (`DateTime t)) (Cookeio.expires cookie4)
145
+
| None -> Alcotest.fail "Expected expiry time for cookie-4"
(* Test cookie-5: secure cookie *)
let cookie5 = find_cookie "cookie-5" in
···
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);
126
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
127
-
"cookie-5 expires" expected_expiry (Cookeio.expires cookie5)
157
+
begin match expected_expiry with
159
+
Alcotest.(check (option expiration_testable))
160
+
"cookie-5 expires" (Some (`DateTime t)) (Cookeio.expires cookie5)
161
+
| 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 *)
···
"file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
Alcotest.(check bool) "file cookie-1 secure" false (Cookeio.secure cookie1);
148
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
183
+
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
155
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
156
-
"file cookie-5 expires" expected_expiry (Cookeio.expires cookie5);
190
+
begin match expected_expiry with
192
+
Alcotest.(check (option expiration_testable))
193
+
"file cookie-5 expires" (Some (`DateTime t)) (Cookeio.expires cookie5)
194
+
| None -> Alcotest.fail "Expected expiry time for cookie-5"
(* Verify subdomain cookie *)
let cookie2 = find_cookie "cookie-2" in
"file cookie-2 domain" "example.com" (Cookeio.domain cookie2);
162
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
201
+
Alcotest.(check (option expiration_testable))
"file cookie-2 expires" None (Cookeio.expires cookie2)
let test_cookie_matching env =
···
266
+
match Ptime.of_float_s 1257894000.0 with
267
+
| Some t -> Some (`DateTime t)
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test"
227
-
~value:"value" ~secure:true ~http_only:false
228
-
?expires:(Ptime.of_float_s 1257894000.0)
229
-
~same_site:`Strict ?max_age:None ~creation_time:Ptime.epoch
230
-
~last_access:Ptime.epoch ()
271
+
~value:"value" ~secure:true ~http_only:false ?expires ~same_site:`Strict
272
+
?max_age:None ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
add_cookie jar test_cookie;
···
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 *)
249
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
250
-
"round trip expires"
251
-
(Ptime.of_float_s 1257894000.0)
252
-
(Cookeio.expires cookie2)
291
+
begin match Ptime.of_float_s 1257894000.0 with
293
+
Alcotest.(check (option expiration_testable))
294
+
"round trip expires" (Some (`DateTime t)) (Cookeio.expires cookie2)
295
+
| None -> Alcotest.fail "Expected expiry time"
let test_cookie_expiry_with_mock_clock () =
Eio_mock.Backend.run @@ fun () ->
···
let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon"
267
-
~value:"value1" ~secure:false ~http_only:false ~expires:expires_soon
268
-
?same_site:None ?max_age:None
311
+
~value:"value1" ~secure:false ~http_only:false
312
+
~expires:(`DateTime expires_soon) ?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
···
let expires_later = Ptime.of_float_s 2000.0 |> Option.get in
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later"
278
-
~value:"value2" ~secure:false ~http_only:false ~expires:expires_later
279
-
?same_site:None ?max_age:None
322
+
~value:"value2" ~secure:false ~http_only:false
323
+
~expires:(`DateTime expires_later) ?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
···
(* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *)
let expected_expiry = Ptime.of_float_s 8600.0 in
347
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
348
-
"expires set from max-age" expected_expiry (Cookeio.expires cookie);
391
+
begin match expected_expiry with
393
+
Alcotest.(check (option expiration_testable))
394
+
"expires set from max-age" (Some (`DateTime t)) (Cookeio.expires cookie)
395
+
| None -> Alcotest.fail "Expected expiry time"
(* Verify creation time matches clock time *)
let expected_creation = Ptime.of_float_s 5000.0 in
···
let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
match expected_expiry with
429
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
430
-
"expires matches parsed value" (Some time) (Cookeio.expires cookie)
477
+
Alcotest.(check (option expiration_testable))
478
+
"expires matches parsed value" (Some (`DateTime time))
479
+
(Cookeio.expires cookie)
| Error _ -> Alcotest.fail "Failed to parse expected expiry time"
let test_samesite_none_validation () =
···
(* Verify expires is also computed correctly *)
let expected_expiry = Ptime.of_float_s 8600.0 in
532
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
533
-
"expires computed from max-age" expected_expiry (Cookeio.expires cookie)
581
+
begin match expected_expiry with
583
+
Alcotest.(check (option expiration_testable))
584
+
"expires computed from max-age" (Some (`DateTime t))
585
+
(Cookeio.expires cookie)
586
+
| None -> Alcotest.fail "Expected expiry time"
let test_max_age_negative_becomes_zero () =
Eio_mock.Backend.run @@ fun () ->
···
(* Verify expires is computed with 0 seconds *)
let expected_expiry = Ptime.of_float_s 5000.0 in
560
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
561
-
"expires computed with 0 seconds" expected_expiry (Cookeio.expires cookie)
614
+
begin match expected_expiry with
616
+
Alcotest.(check (option expiration_testable))
617
+
"expires computed with 0 seconds" (Some (`DateTime t))
618
+
(Cookeio.expires cookie)
619
+
| None -> Alcotest.fail "Expected expiry time"
let string_contains_substring s sub =
···
let expires_time = Ptime.of_float_s 8600.0 |> Option.get in
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"abc123"
584
-
~secure:true ~http_only:true ?expires:(Some expires_time)
643
+
~secure:true ~http_only:true ?expires:(Some (`DateTime expires_time))
?max_age:(Some max_age_span) ?same_site:(Some `Strict)
~creation_time:(Ptime.of_float_s 5000.0 |> Option.get)
~last_access:(Ptime.of_float_s 5000.0 |> Option.get)
···
(* Verify the parsed time matches expected value *)
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
714
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
715
-
"FMT1 expiry correct" expected (Cookeio.expires cookie)
773
+
begin match expected with
775
+
Alcotest.(check (option expiration_testable))
776
+
"FMT1 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
777
+
| None -> Alcotest.fail "Expected expiry time for FMT1"
let test_http_date_fmt2 () =
Eio_mock.Backend.run @@ fun () ->
···
(* Year 15 should be normalized to 2015 *)
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
736
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
737
-
"FMT2 expiry correct with year normalization" expected
738
-
(Cookeio.expires cookie)
799
+
begin match expected with
801
+
Alcotest.(check (option expiration_testable))
802
+
"FMT2 expiry correct with year normalization" (Some (`DateTime t))
803
+
(Cookeio.expires cookie)
804
+
| None -> Alcotest.fail "Expected expiry time for FMT2"
let test_http_date_fmt3 () =
Eio_mock.Backend.run @@ fun () ->
···
(Option.is_some (Cookeio.expires cookie));
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
758
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
759
-
"FMT3 expiry correct" expected (Cookeio.expires cookie)
825
+
begin match expected with
827
+
Alcotest.(check (option expiration_testable))
828
+
"FMT3 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
829
+
| None -> Alcotest.fail "Expected expiry time for FMT3"
let test_http_date_fmt4 () =
Eio_mock.Backend.run @@ fun () ->
···
(Option.is_some (Cookeio.expires cookie));
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
779
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
780
-
"FMT4 expiry correct" expected (Cookeio.expires cookie)
850
+
begin match expected with
852
+
Alcotest.(check (option expiration_testable))
853
+
"FMT4 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
854
+
| None -> Alcotest.fail "Expected expiry time for FMT4"
let test_abbreviated_year_69_to_99 () =
Eio_mock.Backend.run @@ fun () ->
···
let cookie = Option.get cookie_opt in
let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
794
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
795
-
"year 95 becomes 1995" expected (Cookeio.expires cookie);
869
+
begin match expected with
871
+
Alcotest.(check (option expiration_testable))
872
+
"year 95 becomes 1995" (Some (`DateTime t)) (Cookeio.expires cookie)
873
+
| None -> Alcotest.fail "Expected expiry time for year 95"
(* Year 69 should become 1969 *)
let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in
···
let cookie2 = Option.get cookie_opt2 in
let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
804
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
805
-
"year 69 becomes 1969" expected2 (Cookeio.expires cookie2);
883
+
begin match expected2 with
885
+
Alcotest.(check (option expiration_testable))
886
+
"year 69 becomes 1969" (Some (`DateTime t)) (Cookeio.expires cookie2)
887
+
| None -> Alcotest.fail "Expected expiry time for year 69"
(* Year 99 should become 1999 *)
let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in
···
let cookie3 = Option.get cookie_opt3 in
let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
814
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
815
-
"year 99 becomes 1999" expected3 (Cookeio.expires cookie3)
897
+
begin match expected3 with
899
+
Alcotest.(check (option expiration_testable))
900
+
"year 99 becomes 1999" (Some (`DateTime t)) (Cookeio.expires cookie3)
901
+
| None -> Alcotest.fail "Expected expiry time for year 99"
let test_abbreviated_year_0_to_68 () =
Eio_mock.Backend.run @@ fun () ->
···
let cookie = Option.get cookie_opt in
let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
829
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
830
-
"year 25 becomes 2025" expected (Cookeio.expires cookie);
916
+
begin match expected with
918
+
Alcotest.(check (option expiration_testable))
919
+
"year 25 becomes 2025" (Some (`DateTime t)) (Cookeio.expires cookie)
920
+
| None -> Alcotest.fail "Expected expiry time for year 25"
(* Year 0 should become 2000 *)
let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in
···
let cookie2 = Option.get cookie_opt2 in
let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
839
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
840
-
"year 0 becomes 2000" expected2 (Cookeio.expires cookie2);
930
+
begin match expected2 with
932
+
Alcotest.(check (option expiration_testable))
933
+
"year 0 becomes 2000" (Some (`DateTime t)) (Cookeio.expires cookie2)
934
+
| None -> Alcotest.fail "Expected expiry time for year 0"
(* Year 68 should become 2068 *)
let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in
···
let cookie3 = Option.get cookie_opt3 in
let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
849
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
850
-
"year 68 becomes 2068" expected3 (Cookeio.expires cookie3)
944
+
begin match expected3 with
946
+
Alcotest.(check (option expiration_testable))
947
+
"year 68 becomes 2068" (Some (`DateTime t)) (Cookeio.expires cookie3)
948
+
| None -> Alcotest.fail "Expected expiry time for year 68"
let test_rfc3339_still_works () =
Eio_mock.Backend.run @@ fun () ->
···
let expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
875
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
876
-
"RFC 3339 expiry correct" (Some time) (Cookeio.expires cookie)
974
+
Alcotest.(check (option expiration_testable))
975
+
"RFC 3339 expiry correct" (Some (`DateTime time)) (Cookeio.expires cookie)
| Error _ -> Alcotest.fail "Failed to parse expected RFC 3339 time"
let test_invalid_date_format_logs_warning () =
···
Alcotest.(check string) "cookie name correct" "session" (Cookeio.name cookie);
Alcotest.(check string) "cookie value correct" "abc" (Cookeio.value cookie);
(* expires should be None since date was invalid *)
898
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
997
+
Alcotest.(check (option expiration_testable))
"expires is None for invalid date" None (Cookeio.expires cookie)
let test_case_insensitive_month_parsing () =
···
(* Verify the date was parsed correctly regardless of case *)
let expires = Option.get (Cookeio.expires cookie) in
933
-
let year, month, _ = Ptime.to_date expires in
934
-
Alcotest.(check int) (description ^ " year correct") 2015 year;
935
-
Alcotest.(check int)
936
-
(description ^ " month correct (October=10)")
1032
+
match expires with
1033
+
| `DateTime ptime ->
1034
+
let year, month, _ = Ptime.to_date ptime in
1035
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
1036
+
Alcotest.(check int)
1037
+
(description ^ " month correct (October=10)")
1039
+
| `Session -> Alcotest.fail (description ^ " should not be session cookie"))
let test_case_insensitive_gmt_parsing () =
···
(* Verify the date was parsed correctly regardless of GMT case *)
let expires = Option.get (Cookeio.expires cookie) in
972
-
let year, month, day = Ptime.to_date expires in
973
-
Alcotest.(check int) (description ^ " year correct") 2015 year;
974
-
Alcotest.(check int)
975
-
(description ^ " month correct (October=10)")
977
-
Alcotest.(check int) (description ^ " day correct") 21 day)
1074
+
match expires with
1075
+
| `DateTime ptime ->
1076
+
let year, month, day = Ptime.to_date ptime in
1077
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
1078
+
Alcotest.(check int)
1079
+
(description ^ " month correct (October=10)")
1081
+
Alcotest.(check int) (description ^ " day correct") 21 day
1082
+
| `Session -> Alcotest.fail (description ^ " should not be session cookie"))
(** {1 Delta Tracking Tests} *)
···
(* Check expires is in the past *)
let now = Ptime.of_float_s 1000.0 |> Option.get in
match Cookeio.expires removal with
1371
+
| Some (`DateTime exp) ->
"expires is in the past" true
(Ptime.compare exp now < 0)
1270
-
| None -> Alcotest.fail "removal cookie should have expires"
1375
+
| _ -> Alcotest.fail "removal cookie should have DateTime expires"
1377
+
(* ============================================================================ *)
1378
+
(* Priority 2 Tests *)
1379
+
(* ============================================================================ *)
1381
+
(* Priority 2.1: Partitioned Cookies *)
1383
+
let test_partitioned_parsing env =
1384
+
let clock = Eio.Stdenv.clock env in
1386
+
match parse_set_cookie ~clock ~domain:"widget.com" ~path:"/"
1387
+
"id=123; Partitioned; Secure" with
1389
+
Alcotest.(check bool) "partitioned flag" true (partitioned c);
1390
+
Alcotest.(check bool) "secure flag" true (secure c)
1391
+
| None -> Alcotest.fail "Should parse valid Partitioned cookie"
1393
+
let test_partitioned_serialization env =
1394
+
let clock = Eio.Stdenv.clock env in
1395
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
1397
+
let cookie = make ~domain:"widget.com" ~path:"/" ~name:"id" ~value:"123"
1398
+
~secure:true ~partitioned:true
1399
+
~creation_time:now ~last_access:now () in
1401
+
let header = make_set_cookie_header cookie in
1402
+
let contains_substring s sub =
1404
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1406
+
with Not_found -> false
1408
+
let has_partitioned = contains_substring header "Partitioned" in
1409
+
let has_secure = contains_substring header "Secure" in
1410
+
Alcotest.(check bool) "contains Partitioned" true has_partitioned;
1411
+
Alcotest.(check bool) "contains Secure" true has_secure
1413
+
let test_partitioned_requires_secure env =
1414
+
let clock = Eio.Stdenv.clock env in
1416
+
(* Partitioned without Secure should be rejected *)
1417
+
match parse_set_cookie ~clock ~domain:"widget.com" ~path:"/"
1418
+
"id=123; Partitioned" with
1419
+
| None -> () (* Expected *)
1420
+
| Some _ -> Alcotest.fail "Should reject Partitioned without Secure"
1422
+
(* Priority 2.2: Expiration Variants *)
1424
+
let test_expiration_variants env =
1425
+
let clock = Eio.Stdenv.clock env in
1426
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
1427
+
let make_base ~name ?expires () =
1428
+
make ~domain:"ex.com" ~path:"/" ~name ~value:"v"
1429
+
?expires ~creation_time:now ~last_access:now ()
1432
+
(* No expiration *)
1433
+
let c1 = make_base ~name:"no_expiry" () in
1434
+
Alcotest.(check (option expiration_testable)) "no expiration"
1435
+
None (expires c1);
1437
+
(* Session cookie *)
1438
+
let c2 = make_base ~name:"session" ~expires:`Session () in
1439
+
Alcotest.(check (option expiration_testable)) "session cookie"
1440
+
(Some `Session) (expires c2);
1442
+
(* Explicit expiration *)
1443
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
1444
+
let c3 = make_base ~name:"persistent" ~expires:(`DateTime future) () in
1445
+
match expires c3 with
1446
+
| Some (`DateTime t) when Ptime.equal t future -> ()
1447
+
| _ -> Alcotest.fail "Expected DateTime expiration"
1449
+
let test_parse_session_expiration env =
1450
+
let clock = Eio.Stdenv.clock env in
1452
+
(* Expires=0 should parse as Session *)
1453
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
1454
+
"id=123; Expires=0" with
1456
+
Alcotest.(check (option expiration_testable)) "expires=0 is session"
1457
+
(Some `Session) (expires c)
1458
+
| None -> Alcotest.fail "Should parse Expires=0"
1460
+
let test_serialize_expiration_variants env =
1461
+
let clock = Eio.Stdenv.clock env in
1462
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
1463
+
let contains_substring s sub =
1465
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1467
+
with Not_found -> false
1470
+
(* Session cookie serialization *)
1471
+
let c1 = make ~domain:"ex.com" ~path:"/" ~name:"s" ~value:"v"
1472
+
~expires:`Session ~creation_time:now ~last_access:now () in
1473
+
let h1 = make_set_cookie_header c1 in
1474
+
let has_expires = contains_substring h1 "Expires=" in
1475
+
Alcotest.(check bool) "session has Expires" true has_expires;
1477
+
(* DateTime serialization *)
1478
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
1479
+
let c2 = make ~domain:"ex.com" ~path:"/" ~name:"p" ~value:"v"
1480
+
~expires:(`DateTime future) ~creation_time:now ~last_access:now () in
1481
+
let h2 = make_set_cookie_header c2 in
1482
+
let has_expires2 = contains_substring h2 "Expires=" in
1483
+
Alcotest.(check bool) "datetime has Expires" true has_expires2
1485
+
(* Priority 2.3: Value Trimming *)
1487
+
let test_quoted_cookie_values env =
1488
+
let clock = Eio.Stdenv.clock env in
1489
+
let test_cases = [
1490
+
("name=value", "value", "value");
1491
+
("name=\"value\"", "\"value\"", "value");
1492
+
("name=\"partial", "\"partial", "\"partial");
1493
+
("name=\"val\"\"", "\"val\"\"", "val\"");
1494
+
("name=val\"", "val\"", "val\"");
1495
+
("name=\"\"", "\"\"", "");
1498
+
List.iter (fun (input, expected_raw, expected_trimmed) ->
1499
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/" input with
1501
+
Alcotest.(check string)
1502
+
(Printf.sprintf "raw value for %s" input) expected_raw (value c);
1503
+
Alcotest.(check string)
1504
+
(Printf.sprintf "trimmed value for %s" input) expected_trimmed
1506
+
| None -> Alcotest.fail ("Parse failed: " ^ input)
1509
+
let test_trimmed_value_not_used_for_equality env =
1510
+
let clock = Eio.Stdenv.clock env in
1512
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
1513
+
"name=\"value\"" with
1515
+
begin match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
1518
+
(* Different raw values *)
1519
+
Alcotest.(check bool) "different raw values" false
1520
+
(value c1 = value c2);
1521
+
(* Same trimmed values *)
1522
+
Alcotest.(check string) "same trimmed values"
1523
+
(value_trimmed c1) (value_trimmed c2)
1524
+
| None -> Alcotest.fail "Parse failed for unquoted"
1526
+
| None -> Alcotest.fail "Parse failed for quoted"
1528
+
(* Priority 2.4: Cookie Header Parsing *)
1530
+
let test_cookie_header_parsing_basic env =
1531
+
let clock = Eio.Stdenv.clock env in
1532
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/"
1533
+
"session=abc123; theme=dark; lang=en" in
1535
+
let cookies = List.filter_map Result.to_option results in
1536
+
Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
1538
+
let find name_val = List.find (fun c -> name c = name_val) cookies in
1539
+
Alcotest.(check string) "session value" "abc123" (value (find "session"));
1540
+
Alcotest.(check string) "theme value" "dark" (value (find "theme"));
1541
+
Alcotest.(check string) "lang value" "en" (value (find "lang"))
1543
+
let test_cookie_header_defaults env =
1544
+
let clock = Eio.Stdenv.clock env in
1546
+
match of_cookie_header ~clock ~domain:"example.com" ~path:"/app"
1547
+
"session=xyz" with
1549
+
(* Domain and path from request context *)
1550
+
Alcotest.(check string) "domain from context" "example.com" (domain c);
1551
+
Alcotest.(check string) "path from context" "/app" (path c);
1553
+
(* Security flags default to false *)
1554
+
Alcotest.(check bool) "secure default" false (secure c);
1555
+
Alcotest.(check bool) "http_only default" false (http_only c);
1556
+
Alcotest.(check bool) "partitioned default" false (partitioned c);
1558
+
(* Optional attributes default to None *)
1559
+
Alcotest.(check (option expiration_testable)) "no expiration"
1561
+
Alcotest.(check (option span_testable)) "no max_age"
1563
+
Alcotest.(check (option same_site_testable)) "no same_site"
1564
+
None (same_site c)
1565
+
| _ -> Alcotest.fail "Should parse single cookie"
1567
+
let test_cookie_header_edge_cases env =
1568
+
let clock = Eio.Stdenv.clock env in
1570
+
let test input expected_count description =
1571
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/" input in
1572
+
let cookies = List.filter_map Result.to_option results in
1573
+
Alcotest.(check int) description expected_count (List.length cookies)
1576
+
test "" 0 "empty string";
1577
+
test ";;" 0 "only separators";
1578
+
test "a=1;;b=2" 2 "double separator";
1579
+
test " a=1 ; b=2 " 2 "excess whitespace";
1580
+
test " " 0 "only whitespace"
1582
+
let test_cookie_header_with_errors env =
1583
+
let clock = Eio.Stdenv.clock env in
1585
+
(* Mix of valid and invalid cookies *)
1586
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/"
1587
+
"valid=1;=noname;valid2=2" in
1589
+
Alcotest.(check int) "total results" 3 (List.length results);
1591
+
let successes = List.filter Result.is_ok results in
1592
+
let errors = List.filter Result.is_error results in
1594
+
Alcotest.(check int) "successful parses" 2 (List.length successes);
1595
+
Alcotest.(check int) "failed parses" 1 (List.length errors);
1597
+
(* Error should have descriptive message *)
1598
+
let contains_substring s sub =
1600
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1602
+
with Not_found -> false
1604
+
begin match List.hd errors with
1606
+
let has_name = contains_substring msg "name" in
1607
+
let has_empty = contains_substring msg "empty" in
1608
+
Alcotest.(check bool) "error mentions name or empty" true
1609
+
(has_name || has_empty)
1610
+
| Ok _ -> Alcotest.fail "Expected error"
1613
+
(* Max-Age and Expires Interaction *)
1615
+
let test_max_age_and_expires_both_present env =
1616
+
let clock = Eio.Stdenv.clock env in
1617
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
1618
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 7200) |> Option.get in
1620
+
(* Create cookie with both *)
1621
+
let cookie = make ~domain:"ex.com" ~path:"/" ~name:"dual" ~value:"val"
1622
+
~max_age:(Ptime.Span.of_int_s 3600)
1623
+
~expires:(`DateTime future)
1624
+
~creation_time:now ~last_access:now () in
1626
+
(* Both should be present *)
1627
+
begin match max_age cookie with
1629
+
begin match Ptime.Span.to_int_s span with
1631
+
Alcotest.(check int64) "max_age present" 3600L (Int64.of_int s)
1632
+
| None -> Alcotest.fail "max_age span could not be converted to int"
1634
+
| None -> Alcotest.fail "max_age should be present"
1637
+
begin match expires cookie with
1638
+
| Some (`DateTime t) when Ptime.equal t future -> ()
1639
+
| _ -> Alcotest.fail "expires should be present"
1642
+
(* Both should appear in serialization *)
1643
+
let header = make_set_cookie_header cookie in
1644
+
let contains_substring s sub =
1646
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1648
+
with Not_found -> false
1650
+
let has_max_age = contains_substring header "Max-Age=3600" in
1651
+
let has_expires = contains_substring header "Expires=" in
1652
+
Alcotest.(check bool) "contains Max-Age" true has_max_age;
1653
+
Alcotest.(check bool) "contains Expires" true has_expires
1655
+
let test_parse_max_age_and_expires env =
1656
+
let clock = Eio.Stdenv.clock env in
1658
+
(* Parse Set-Cookie with both attributes *)
1659
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
1660
+
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT" with
1662
+
(* Both should be stored *)
1663
+
begin match max_age c with
1665
+
begin match Ptime.Span.to_int_s span with
1667
+
Alcotest.(check int64) "max_age parsed" 3600L (Int64.of_int s)
1668
+
| None -> Alcotest.fail "max_age span could not be converted to int"
1670
+
| None -> Alcotest.fail "max_age should be parsed"
1673
+
begin match expires c with
1674
+
| Some (`DateTime _) -> ()
1675
+
| _ -> Alcotest.fail "expires should be parsed"
1677
+
| None -> Alcotest.fail "Should parse cookie with both attributes"
Eio_main.run @@ fun env ->
···
test_case_insensitive_month_parsing;
test_case "Case-insensitive GMT parsing" `Quick
test_case_insensitive_gmt_parsing;
1772
+
test_case "parse partitioned cookie" `Quick (fun () ->
1773
+
test_partitioned_parsing env);
1774
+
test_case "serialize partitioned cookie" `Quick (fun () ->
1775
+
test_partitioned_serialization env);
1776
+
test_case "partitioned requires secure" `Quick (fun () ->
1777
+
test_partitioned_requires_secure env);
1781
+
test_case "expiration variants" `Quick (fun () ->
1782
+
test_expiration_variants env);
1783
+
test_case "parse session expiration" `Quick (fun () ->
1784
+
test_parse_session_expiration env);
1785
+
test_case "serialize expiration variants" `Quick (fun () ->
1786
+
test_serialize_expiration_variants env);
1788
+
( "value_trimming",
1790
+
test_case "quoted values" `Quick (fun () ->
1791
+
test_quoted_cookie_values env);
1792
+
test_case "trimmed not used for equality" `Quick (fun () ->
1793
+
test_trimmed_value_not_used_for_equality env);
1795
+
( "cookie_header",
1797
+
test_case "parse basic" `Quick (fun () ->
1798
+
test_cookie_header_parsing_basic env);
1799
+
test_case "default values" `Quick (fun () ->
1800
+
test_cookie_header_defaults env);
1801
+
test_case "edge cases" `Quick (fun () ->
1802
+
test_cookie_header_edge_cases env);
1803
+
test_case "multiple with errors" `Quick (fun () ->
1804
+
test_cookie_header_with_errors env);
1806
+
( "max_age_expires_interaction",
1808
+
test_case "both present" `Quick (fun () ->
1809
+
test_max_age_and_expires_both_present env);
1810
+
test_case "parse both" `Quick (fun () ->
1811
+
test_parse_max_age_and_expires env);