···
+
(* Testable helpers for Priority 2 types *)
+
let expiration_testable : Cookeio.Expiration.t Alcotest.testable =
+
Alcotest.testable Cookeio.Expiration.pp Cookeio.Expiration.equal
+
let span_testable : Ptime.Span.t Alcotest.testable =
+
Alcotest.testable Ptime.Span.pp Ptime.Span.equal
+
let same_site_testable : Cookeio.SameSite.t Alcotest.testable =
+
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; \
+
partitioned=%b; expires=%a; max_age=%a; same_site=%a }"
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c)
+
(Cookeio.secure c) (Cookeio.http_only c) (Cookeio.partitioned c)
+
(Format.pp_print_option
+
| `Session -> Format.pp_print_string ppf "Session"
+
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t))
(Format.pp_print_option Ptime.Span.pp)
+
(Format.pp_print_option
+
| `Strict -> Format.pp_print_string ppf "Strict"
+
| `Lax -> Format.pp_print_string ppf "Lax"
+
| `None -> Format.pp_print_string ppf "None"))
+
let expires_equal e1 e2 =
+
| Some `Session, Some `Session -> true
+
| 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
+
&& Cookeio.partitioned c1 = Cookeio.partitioned c2
+
&& 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);
+
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);
+
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);
+
begin match expected_expiry with
+
Alcotest.(check (option expiration_testable))
+
"cookie-3 expires" (Some (`DateTime t)) (Cookeio.expires cookie3)
+
| 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);
+
begin match expected_expiry with
+
Alcotest.(check (option expiration_testable))
+
"cookie-4 expires" (Some (`DateTime t)) (Cookeio.expires cookie4)
+
| 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);
+
begin match expected_expiry with
+
Alcotest.(check (option expiration_testable))
+
"cookie-5 expires" (Some (`DateTime t)) (Cookeio.expires cookie5)
+
| 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);
+
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
+
begin match expected_expiry with
+
Alcotest.(check (option expiration_testable))
+
"file cookie-5 expires" (Some (`DateTime t)) (Cookeio.expires cookie5)
+
| 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);
+
Alcotest.(check (option expiration_testable))
"file cookie-2 expires" None (Cookeio.expires cookie2)
let test_cookie_matching env =
···
+
match Ptime.of_float_s 1257894000.0 with
+
| Some t -> Some (`DateTime t)
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test"
+
~value:"value" ~secure:true ~http_only:false ?expires ~same_site:`Strict
+
?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 *)
+
begin match Ptime.of_float_s 1257894000.0 with
+
Alcotest.(check (option expiration_testable))
+
"round trip expires" (Some (`DateTime t)) (Cookeio.expires cookie2)
+
| 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"
+
~value:"value1" ~secure:false ~http_only:false
+
~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"
+
~value:"value2" ~secure:false ~http_only:false
+
~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
+
begin match expected_expiry with
+
Alcotest.(check (option expiration_testable))
+
"expires set from max-age" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| 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
+
Alcotest.(check (option expiration_testable))
+
"expires matches parsed value" (Some (`DateTime time))
+
(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
+
begin match expected_expiry with
+
Alcotest.(check (option expiration_testable))
+
"expires computed from max-age" (Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| 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
+
begin match expected_expiry with
+
Alcotest.(check (option expiration_testable))
+
"expires computed with 0 seconds" (Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| 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"
+
~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
+
begin match expected with
+
Alcotest.(check (option expiration_testable))
+
"FMT1 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| 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
+
begin match expected with
+
Alcotest.(check (option expiration_testable))
+
"FMT2 expiry correct with year normalization" (Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| 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
+
begin match expected with
+
Alcotest.(check (option expiration_testable))
+
"FMT3 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| 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
+
begin match expected with
+
Alcotest.(check (option expiration_testable))
+
"FMT4 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| 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
+
begin match expected with
+
Alcotest.(check (option expiration_testable))
+
"year 95 becomes 1995" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| 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
+
begin match expected2 with
+
Alcotest.(check (option expiration_testable))
+
"year 69 becomes 1969" (Some (`DateTime t)) (Cookeio.expires cookie2)
+
| 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
+
begin match expected3 with
+
Alcotest.(check (option expiration_testable))
+
"year 99 becomes 1999" (Some (`DateTime t)) (Cookeio.expires cookie3)
+
| 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
+
begin match expected with
+
Alcotest.(check (option expiration_testable))
+
"year 25 becomes 2025" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| 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
+
begin match expected2 with
+
Alcotest.(check (option expiration_testable))
+
"year 0 becomes 2000" (Some (`DateTime t)) (Cookeio.expires cookie2)
+
| 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
+
begin match expected3 with
+
Alcotest.(check (option expiration_testable))
+
"year 68 becomes 2068" (Some (`DateTime t)) (Cookeio.expires cookie3)
+
| 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
+
Alcotest.(check (option expiration_testable))
+
"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 *)
+
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
+
let year, month, _ = Ptime.to_date ptime in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
(description ^ " month correct (October=10)")
+
| `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
+
let year, month, day = Ptime.to_date ptime in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
(description ^ " month correct (October=10)")
+
Alcotest.(check int) (description ^ " day correct") 21 day
+
| `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
+
| Some (`DateTime exp) ->
"expires is in the past" true
(Ptime.compare exp now < 0)
+
| _ -> Alcotest.fail "removal cookie should have DateTime expires"
+
(* ============================================================================ *)
+
(* ============================================================================ *)
+
(* Priority 2.1: Partitioned Cookies *)
+
let test_partitioned_parsing env =
+
let clock = Eio.Stdenv.clock env in
+
match parse_set_cookie ~clock ~domain:"widget.com" ~path:"/"
+
"id=123; Partitioned; Secure" with
+
Alcotest.(check bool) "partitioned flag" true (partitioned c);
+
Alcotest.(check bool) "secure flag" true (secure c)
+
| None -> Alcotest.fail "Should parse valid Partitioned cookie"
+
let test_partitioned_serialization env =
+
let clock = Eio.Stdenv.clock env in
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
+
let cookie = make ~domain:"widget.com" ~path:"/" ~name:"id" ~value:"123"
+
~secure:true ~partitioned:true
+
~creation_time:now ~last_access:now () in
+
let header = make_set_cookie_header cookie in
+
let contains_substring s sub =
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
with Not_found -> false
+
let has_partitioned = contains_substring header "Partitioned" in
+
let has_secure = contains_substring header "Secure" in
+
Alcotest.(check bool) "contains Partitioned" true has_partitioned;
+
Alcotest.(check bool) "contains Secure" true has_secure
+
let test_partitioned_requires_secure env =
+
let clock = Eio.Stdenv.clock env in
+
(* Partitioned without Secure should be rejected *)
+
match parse_set_cookie ~clock ~domain:"widget.com" ~path:"/"
+
"id=123; Partitioned" with
+
| None -> () (* Expected *)
+
| Some _ -> Alcotest.fail "Should reject Partitioned without Secure"
+
(* Priority 2.2: Expiration Variants *)
+
let test_expiration_variants env =
+
let clock = Eio.Stdenv.clock env in
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
+
let make_base ~name ?expires () =
+
make ~domain:"ex.com" ~path:"/" ~name ~value:"v"
+
?expires ~creation_time:now ~last_access:now ()
+
let c1 = make_base ~name:"no_expiry" () in
+
Alcotest.(check (option expiration_testable)) "no expiration"
+
let c2 = make_base ~name:"session" ~expires:`Session () in
+
Alcotest.(check (option expiration_testable)) "session cookie"
+
(Some `Session) (expires c2);
+
(* Explicit expiration *)
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
+
let c3 = make_base ~name:"persistent" ~expires:(`DateTime future) () in
+
| Some (`DateTime t) when Ptime.equal t future -> ()
+
| _ -> Alcotest.fail "Expected DateTime expiration"
+
let test_parse_session_expiration env =
+
let clock = Eio.Stdenv.clock env in
+
(* Expires=0 should parse as Session *)
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
+
"id=123; Expires=0" with
+
Alcotest.(check (option expiration_testable)) "expires=0 is session"
+
(Some `Session) (expires c)
+
| None -> Alcotest.fail "Should parse Expires=0"
+
let test_serialize_expiration_variants env =
+
let clock = Eio.Stdenv.clock env in
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
+
let contains_substring s sub =
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
with Not_found -> false
+
(* Session cookie serialization *)
+
let c1 = make ~domain:"ex.com" ~path:"/" ~name:"s" ~value:"v"
+
~expires:`Session ~creation_time:now ~last_access:now () in
+
let h1 = make_set_cookie_header c1 in
+
let has_expires = contains_substring h1 "Expires=" in
+
Alcotest.(check bool) "session has Expires" true has_expires;
+
(* DateTime serialization *)
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
+
let c2 = make ~domain:"ex.com" ~path:"/" ~name:"p" ~value:"v"
+
~expires:(`DateTime future) ~creation_time:now ~last_access:now () in
+
let h2 = make_set_cookie_header c2 in
+
let has_expires2 = contains_substring h2 "Expires=" in
+
Alcotest.(check bool) "datetime has Expires" true has_expires2
+
(* Priority 2.3: Value Trimming *)
+
let test_quoted_cookie_values env =
+
let clock = Eio.Stdenv.clock env in
+
("name=value", "value", "value");
+
("name=\"value\"", "\"value\"", "value");
+
("name=\"partial", "\"partial", "\"partial");
+
("name=\"val\"\"", "\"val\"\"", "val\"");
+
("name=val\"", "val\"", "val\"");
+
("name=\"\"", "\"\"", "");
+
List.iter (fun (input, expected_raw, expected_trimmed) ->
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/" input with
+
Alcotest.(check string)
+
(Printf.sprintf "raw value for %s" input) expected_raw (value c);
+
Alcotest.(check string)
+
(Printf.sprintf "trimmed value for %s" input) expected_trimmed
+
| None -> Alcotest.fail ("Parse failed: " ^ input)
+
let test_trimmed_value_not_used_for_equality env =
+
let clock = Eio.Stdenv.clock env in
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
+
begin match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
+
(* Different raw values *)
+
Alcotest.(check bool) "different raw values" false
+
(* Same trimmed values *)
+
Alcotest.(check string) "same trimmed values"
+
(value_trimmed c1) (value_trimmed c2)
+
| None -> Alcotest.fail "Parse failed for unquoted"
+
| None -> Alcotest.fail "Parse failed for quoted"
+
(* Priority 2.4: Cookie Header Parsing *)
+
let test_cookie_header_parsing_basic env =
+
let clock = Eio.Stdenv.clock env in
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/"
+
"session=abc123; theme=dark; lang=en" in
+
let cookies = List.filter_map Result.to_option results in
+
Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
+
let find name_val = List.find (fun c -> name c = name_val) cookies in
+
Alcotest.(check string) "session value" "abc123" (value (find "session"));
+
Alcotest.(check string) "theme value" "dark" (value (find "theme"));
+
Alcotest.(check string) "lang value" "en" (value (find "lang"))
+
let test_cookie_header_defaults env =
+
let clock = Eio.Stdenv.clock env in
+
match of_cookie_header ~clock ~domain:"example.com" ~path:"/app"
+
(* Domain and path from request context *)
+
Alcotest.(check string) "domain from context" "example.com" (domain c);
+
Alcotest.(check string) "path from context" "/app" (path c);
+
(* Security flags default to false *)
+
Alcotest.(check bool) "secure default" false (secure c);
+
Alcotest.(check bool) "http_only default" false (http_only c);
+
Alcotest.(check bool) "partitioned default" false (partitioned c);
+
(* Optional attributes default to None *)
+
Alcotest.(check (option expiration_testable)) "no expiration"
+
Alcotest.(check (option span_testable)) "no max_age"
+
Alcotest.(check (option same_site_testable)) "no same_site"
+
| _ -> Alcotest.fail "Should parse single cookie"
+
let test_cookie_header_edge_cases env =
+
let clock = Eio.Stdenv.clock env in
+
let test input expected_count description =
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/" input in
+
let cookies = List.filter_map Result.to_option results in
+
Alcotest.(check int) description expected_count (List.length cookies)
+
test "" 0 "empty string";
+
test ";;" 0 "only separators";
+
test "a=1;;b=2" 2 "double separator";
+
test " a=1 ; b=2 " 2 "excess whitespace";
+
test " " 0 "only whitespace"
+
let test_cookie_header_with_errors env =
+
let clock = Eio.Stdenv.clock env in
+
(* Mix of valid and invalid cookies *)
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/"
+
"valid=1;=noname;valid2=2" in
+
Alcotest.(check int) "total results" 3 (List.length results);
+
let successes = List.filter Result.is_ok results in
+
let errors = List.filter Result.is_error results in
+
Alcotest.(check int) "successful parses" 2 (List.length successes);
+
Alcotest.(check int) "failed parses" 1 (List.length errors);
+
(* Error should have descriptive message *)
+
let contains_substring s sub =
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
with Not_found -> false
+
begin match List.hd errors with
+
let has_name = contains_substring msg "name" in
+
let has_empty = contains_substring msg "empty" in
+
Alcotest.(check bool) "error mentions name or empty" true
+
(has_name || has_empty)
+
| Ok _ -> Alcotest.fail "Expected error"
+
(* Max-Age and Expires Interaction *)
+
let test_max_age_and_expires_both_present env =
+
let clock = Eio.Stdenv.clock env in
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 7200) |> Option.get in
+
(* Create cookie with both *)
+
let cookie = make ~domain:"ex.com" ~path:"/" ~name:"dual" ~value:"val"
+
~max_age:(Ptime.Span.of_int_s 3600)
+
~expires:(`DateTime future)
+
~creation_time:now ~last_access:now () in
+
(* Both should be present *)
+
begin match max_age cookie with
+
begin match Ptime.Span.to_int_s span with
+
Alcotest.(check int64) "max_age present" 3600L (Int64.of_int s)
+
| None -> Alcotest.fail "max_age span could not be converted to int"
+
| None -> Alcotest.fail "max_age should be present"
+
begin match expires cookie with
+
| Some (`DateTime t) when Ptime.equal t future -> ()
+
| _ -> Alcotest.fail "expires should be present"
+
(* Both should appear in serialization *)
+
let header = make_set_cookie_header cookie in
+
let contains_substring s sub =
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
with Not_found -> false
+
let has_max_age = contains_substring header "Max-Age=3600" in
+
let has_expires = contains_substring header "Expires=" in
+
Alcotest.(check bool) "contains Max-Age" true has_max_age;
+
Alcotest.(check bool) "contains Expires" true has_expires
+
let test_parse_max_age_and_expires env =
+
let clock = Eio.Stdenv.clock env in
+
(* Parse Set-Cookie with both attributes *)
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
+
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT" with
+
(* Both should be stored *)
+
begin match max_age c with
+
begin match Ptime.Span.to_int_s span with
+
Alcotest.(check int64) "max_age parsed" 3600L (Int64.of_int s)
+
| None -> Alcotest.fail "max_age span could not be converted to int"
+
| None -> Alcotest.fail "max_age should be parsed"
+
begin match expires c with
+
| Some (`DateTime _) -> ()
+
| _ -> Alcotest.fail "expires should be parsed"
+
| 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;
+
test_case "parse partitioned cookie" `Quick (fun () ->
+
test_partitioned_parsing env);
+
test_case "serialize partitioned cookie" `Quick (fun () ->
+
test_partitioned_serialization env);
+
test_case "partitioned requires secure" `Quick (fun () ->
+
test_partitioned_requires_secure env);
+
test_case "expiration variants" `Quick (fun () ->
+
test_expiration_variants env);
+
test_case "parse session expiration" `Quick (fun () ->
+
test_parse_session_expiration env);
+
test_case "serialize expiration variants" `Quick (fun () ->
+
test_serialize_expiration_variants env);
+
test_case "quoted values" `Quick (fun () ->
+
test_quoted_cookie_values env);
+
test_case "trimmed not used for equality" `Quick (fun () ->
+
test_trimmed_value_not_used_for_equality env);
+
test_case "parse basic" `Quick (fun () ->
+
test_cookie_header_parsing_basic env);
+
test_case "default values" `Quick (fun () ->
+
test_cookie_header_defaults env);
+
test_case "edge cases" `Quick (fun () ->
+
test_cookie_header_edge_cases env);
+
test_case "multiple with errors" `Quick (fun () ->
+
test_cookie_header_with_errors env);
+
( "max_age_expires_interaction",
+
test_case "both present" `Quick (fun () ->
+
test_max_age_and_expires_both_present env);
+
test_case "parse both" `Quick (fun () ->
+
test_parse_max_age_and_expires env);