···
"{ name=%S; value=%S; domain=%S; path=%S; secure=%b; http_only=%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)
(Format.pp_print_option Ptime.pp)
+
(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"))
+
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
&& Option.equal Ptime.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))
let test_load_mozilla_cookies env =
···
(* Test cookie-1: session cookie on exact domain *)
let cookie1 = find_cookie "cookie-1" in
+
Alcotest.(check string)
+
"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);
···
| `Lax -> Format.pp_print_string ppf "Lax"
| `None -> Format.pp_print_string ppf "None")
+
"cookie-1 same_site" None
+
(Cookeio.same_site cookie1);
(* Test cookie-2: session cookie on subdomain pattern *)
let cookie2 = find_cookie "cookie-2" in
+
Alcotest.(check string)
+
"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);
···
(* 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
+
Alcotest.(check string)
+
"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);
···
(* Test cookie-4: another non-session cookie *)
let cookie4 = find_cookie "cookie-4" in
+
Alcotest.(check string)
+
"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);
···
(* Test cookie-5: secure cookie *)
let cookie5 = find_cookie "cookie-5" in
+
Alcotest.(check string)
+
"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);
···
(* 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);
+
Alcotest.(check string)
+
"file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
Alcotest.(check bool) "file cookie-1 secure" false (Cookeio.secure cookie1);
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
"file cookie-1 expires" None (Cookeio.expires cookie1);
···
(* Verify subdomain cookie *)
let cookie2 = find_cookie "cookie-2" in
+
Alcotest.(check string)
+
"file cookie-2 domain" "example.com" (Cookeio.domain cookie2);
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
"file cookie-2 expires" None (Cookeio.expires cookie2)
···
(* Add test cookies with different domain patterns *)
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"exact" ~value:"test1"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"subdomain"
+
~value:"test2" ~secure:false ~http_only:false ?expires:None
+
?same_site:None ?max_age:None ~creation_time:Ptime.epoch
+
~last_access:Ptime.epoch ()
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"secure" ~value:"test3"
+
~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;
+
(* 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);
+
(* Test subdomain matching - all cookies should match subdomains now *)
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "subdomain cookies count" 2 (List.length cookies_sub)
let clock = Eio.Stdenv.clock env in
···
+
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test"
+
~value:"value" ~secure:true ~http_only:false
+
?expires:(Ptime.of_float_s 1257894000.0)
+
~same_site:`Strict ?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);
+
Alcotest.(check string)
+
"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 *)
···
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon"
~value:"value1" ~secure:false ~http_only:false ~expires: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)
···
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later"
~value:"value2" ~secure:false ~http_only:false ~expires: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)
···
(* Add a session cookie (no expiry) *)
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"value3"
+
~secure:false ~http_only:false ?expires:None ?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 cookies = get_all_cookies jar in
let names = List.map Cookeio.name cookies |> List.sort String.compare in
Alcotest.(check (list string))
+
"remaining cookies after 1600s"
+
[ "expires_later"; "session" ]
(* Advance time to 2100.0 - second cookie should expire *)
Eio_mock.Clock.set_time clock 2100.0;
···
Alcotest.(check int) "after second expiry" 1 (count jar);
let remaining = get_all_cookies jar in
+
Alcotest.(check string)
+
"only session cookie remains" "session"
(Cookeio.name (List.hd remaining))
let test_max_age_parsing_with_mock_clock () =
···
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 3000.0 |> Option.get)
~last_access:(Ptime.of_float_s 3000.0 |> Option.get)
···
let cookie = Option.get cookie_opt in
Alcotest.(check string) "cookie name" "id" (Cookeio.name cookie);
Alcotest.(check string) "cookie value" "xyz789" (Cookeio.value cookie);
+
Alcotest.(check string) "cookie domain" "example.com" (Cookeio.domain cookie);
Alcotest.(check string) "cookie path" "/" (Cookeio.path cookie);
(* Verify expires is parsed correctly *)
(Option.is_some (Cookeio.expires cookie));
(* Verify the specific expiry time parsed from the RFC3339 date *)
···
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" invalid_header
+
"invalid cookie rejected" true
+
(Option.is_none cookie_opt);
(* This should be accepted: SameSite=None with Secure *)
let valid_header = "token=abc; SameSite=None; Secure" in
···
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" valid_header
+
"valid cookie accepted" true
+
(Option.is_some cookie_opt2);
let cookie = Option.get cookie_opt2 in
Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie);
···
"samesite is None" (Some `None) (Cookeio.same_site cookie)
+
let test_domain_normalization () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Test parsing ".example.com" stores as "example.com" *)
+
let header = "test=value; Domain=.example.com" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check string)
+
"domain normalized" "example.com" (Cookeio.domain cookie);
+
(* Test round-trip through Mozilla format normalizes domains *)
+
Cookeio.make ~domain:".example.com" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_cookie jar test_cookie;
+
let mozilla_format = to_mozilla_format jar in
+
let jar2 = from_mozilla_format ~clock mozilla_format in
+
let cookies2 = get_all_cookies jar2 in
+
Alcotest.(check int) "one cookie" 1 (List.length cookies2);
+
Alcotest.(check string)
+
"domain normalized after round-trip" "example.com"
+
(Cookeio.domain (List.hd cookies2))
+
let test_max_age_stored_separately () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
(* Parse a Set-Cookie header with Max-Age *)
+
let header = "session=abc123; Max-Age=3600" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(* Verify max_age is stored as a Ptime.Span *)
+
(Option.is_some (Cookeio.max_age cookie));
+
let max_age_span = Option.get (Cookeio.max_age cookie) in
+
Alcotest.(check (option int))
+
"max_age is 3600 seconds" (Some 3600)
+
(Ptime.Span.to_int_s max_age_span);
+
(* Verify expires is also computed correctly *)
+
let expected_expiry = Ptime.of_float_s 8600.0 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"expires computed from max-age" expected_expiry (Cookeio.expires cookie)
+
let test_max_age_negative_becomes_zero () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
(* Parse a Set-Cookie header with negative Max-Age *)
+
let header = "session=abc123; Max-Age=-100" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(* Verify max_age is stored as 0 per RFC 6265 *)
+
(Option.is_some (Cookeio.max_age cookie));
+
let max_age_span = Option.get (Cookeio.max_age cookie) in
+
Alcotest.(check (option int))
+
"negative max_age becomes 0" (Some 0)
+
(Ptime.Span.to_int_s max_age_span);
+
(* Verify expires is computed with 0 seconds *)
+
let expected_expiry = Ptime.of_float_s 5000.0 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"expires computed with 0 seconds" expected_expiry (Cookeio.expires cookie)
+
let string_contains_substring s sub =
+
let len = String.length sub in
+
if i + len > String.length s then false
+
else if String.sub s i len = sub then true
+
let test_make_set_cookie_header_includes_max_age () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
(* Create a cookie with max_age *)
+
let max_age_span = Ptime.Span.of_int_s 3600 in
+
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 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)
+
let header = make_set_cookie_header cookie in
+
(* Verify the header includes Max-Age *)
+
"header includes Max-Age" true
+
(string_contains_substring header "Max-Age=3600");
+
(* Verify the header includes Expires *)
+
"header includes Expires" true
+
(string_contains_substring header "Expires=");
+
(* Verify the header includes other attributes *)
+
"header includes Secure" true
+
(string_contains_substring header "Secure");
+
"header includes HttpOnly" true
+
(string_contains_substring header "HttpOnly");
+
"header includes SameSite" true
+
(string_contains_substring header "SameSite=Strict")
+
let test_max_age_round_trip () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
(* Parse a cookie with Max-Age *)
+
let header = "session=xyz; Max-Age=7200; Secure; HttpOnly" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(* Generate Set-Cookie header from the cookie *)
+
let set_cookie_header = make_set_cookie_header cookie in
+
Eio_mock.Clock.set_time clock 5000.0;
+
(* Reset clock to same time *)
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" set_cookie_header
+
Alcotest.(check bool) "cookie re-parsed" true (Option.is_some cookie2_opt);
+
let cookie2 = Option.get cookie2_opt in
+
(* Verify max_age is preserved *)
+
Alcotest.(check (option int))
+
(Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie)))
+
(Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie2)))
+
let test_domain_matching () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 2000.0;
+
(* Create a cookie with domain "example.com" *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 2000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 2000.0 |> Option.get)
+
(* Test "example.com" cookie matches "example.com" request *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "matches exact domain" 1 (List.length cookies1);
+
(* Test "example.com" cookie matches "sub.example.com" request *)
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "matches subdomain" 1 (List.length cookies2);
+
(* Test "example.com" cookie matches "deep.sub.example.com" request *)
+
get_cookies jar ~clock ~domain:"deep.sub.example.com" ~path:"/"
+
Alcotest.(check int) "matches deep subdomain" 1 (List.length cookies3);
+
(* Test "example.com" cookie doesn't match "notexample.com" *)
+
get_cookies jar ~clock ~domain:"notexample.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "doesn't match different domain" 0 (List.length cookies4);
+
(* Test "example.com" cookie doesn't match "fakeexample.com" *)
+
get_cookies jar ~clock ~domain:"fakeexample.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "doesn't match prefix domain" 0 (List.length cookies5)
+
(** {1 HTTP Date Parsing Tests} *)
+
let test_http_date_fmt1 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Test FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
+
let header = "session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "FMT1 cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(Option.is_some (Cookeio.expires cookie));
+
(* Verify the parsed time matches expected value *)
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"FMT1 expiry correct" expected (Cookeio.expires cookie)
+
let test_http_date_fmt2 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Test FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850 with abbreviated year) *)
+
let header = "session=abc; Expires=Wednesday, 21-Oct-15 07:28:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "FMT2 cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(Option.is_some (Cookeio.expires cookie));
+
(* Year 15 should be normalized to 2015 *)
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"FMT2 expiry correct with year normalization" expected
+
(Cookeio.expires cookie)
+
let test_http_date_fmt3 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Test FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
+
let header = "session=abc; Expires=Wed Oct 21 07:28:00 2015" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "FMT3 cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(Option.is_some (Cookeio.expires cookie));
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"FMT3 expiry correct" expected (Cookeio.expires cookie)
+
let test_http_date_fmt4 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Test FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
+
let header = "session=abc; Expires=Wed, 21-Oct-2015 07:28:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "FMT4 cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(Option.is_some (Cookeio.expires cookie));
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"FMT4 expiry correct" expected (Cookeio.expires cookie)
+
let test_abbreviated_year_69_to_99 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Year 95 should become 1995 *)
+
let header = "session=abc; Expires=Wed, 21-Oct-95 07:28:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
let cookie = Option.get cookie_opt in
+
let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 95 becomes 1995" expected (Cookeio.expires cookie);
+
(* Year 69 should become 1969 *)
+
let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header2
+
let cookie2 = Option.get cookie_opt2 in
+
let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 69 becomes 1969" expected2 (Cookeio.expires cookie2);
+
(* Year 99 should become 1999 *)
+
let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header3
+
let cookie3 = Option.get cookie_opt3 in
+
let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 99 becomes 1999" expected3 (Cookeio.expires cookie3)
+
let test_abbreviated_year_0_to_68 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Year 25 should become 2025 *)
+
let header = "session=abc; Expires=Wed, 21-Oct-25 07:28:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
let cookie = Option.get cookie_opt in
+
let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 25 becomes 2025" expected (Cookeio.expires cookie);
+
(* Year 0 should become 2000 *)
+
let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header2
+
let cookie2 = Option.get cookie_opt2 in
+
let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 0 becomes 2000" expected2 (Cookeio.expires cookie2);
+
(* Year 68 should become 2068 *)
+
let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header3
+
let cookie3 = Option.get cookie_opt3 in
+
let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 68 becomes 2068" expected3 (Cookeio.expires cookie3)
+
let test_rfc3339_still_works () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Ensure RFC 3339 format still works for backward compatibility *)
+
let header = "session=abc; Expires=2025-10-21T07:28:00Z" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
"RFC 3339 cookie parsed" true
+
(Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
"RFC 3339 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
(* Verify the time was parsed correctly *)
+
let expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"RFC 3339 expiry correct" (Some time) (Cookeio.expires cookie)
+
| Error _ -> Alcotest.fail "Failed to parse expected RFC 3339 time"
+
let test_invalid_date_format_logs_warning () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Invalid date format should log a warning but still parse the cookie *)
+
let header = "session=abc; Expires=InvalidDate" in
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
(* Cookie should still be parsed, just without expires *)
+
"cookie parsed despite invalid date" true
+
(Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
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 (Alcotest.testable Ptime.pp Ptime.equal)))
+
"expires is None for invalid date" None (Cookeio.expires cookie)
+
let test_case_insensitive_month_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Test various case combinations for month names *)
+
("session=abc; Expires=Wed, 21 oct 2015 07:28:00 GMT", "lowercase month");
+
("session=abc; Expires=Wed, 21 OCT 2015 07:28:00 GMT", "uppercase month");
+
("session=abc; Expires=Wed, 21 OcT 2015 07:28:00 GMT", "mixed case month");
+
("session=abc; Expires=Wed, 21 oCt 2015 07:28:00 GMT", "weird case month");
+
(fun (header, description) ->
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
(description ^ " parsed") true
+
(Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(description ^ " has expiry")
+
(Option.is_some (Cookeio.expires cookie));
+
(* Verify the date was parsed correctly regardless of case *)
+
let expires = Option.get (Cookeio.expires cookie) in
+
let year, month, _ = Ptime.to_date expires in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
(description ^ " month correct (October=10)")
+
let test_case_insensitive_gmt_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Test various case combinations for GMT timezone *)
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT", "uppercase GMT");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 gmt", "lowercase gmt");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 Gmt", "mixed case Gmt");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GmT", "weird case GmT");
+
(fun (header, description) ->
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
(description ^ " parsed") true
+
(Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
(description ^ " has expiry")
+
(Option.is_some (Cookeio.expires cookie));
+
(* 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 expires in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
(description ^ " month correct (October=10)")
+
Alcotest.(check int) (description ^ " day correct") 21 day)
+
(** {1 Delta Tracking Tests} *)
+
let test_add_original_not_in_delta () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_original jar cookie;
+
(* Delta should be empty *)
+
let delta = Cookeio.delta jar in
+
Alcotest.(check int) "delta is empty" 0 (List.length delta);
+
(* But the cookie should be in the jar *)
+
Alcotest.(check int) "jar count is 1" 1 (count jar)
+
let test_add_cookie_appears_in_delta () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?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)
+
(* Delta should contain the cookie *)
+
let delta = Cookeio.delta jar in
+
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
+
let delta_cookie = List.hd delta in
+
Alcotest.(check string) "delta cookie name" "test" (Cookeio.name delta_cookie);
+
Alcotest.(check string)
+
"delta cookie value" "value"
+
(Cookeio.value delta_cookie)
+
let test_remove_original_creates_removal_cookie () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_original jar cookie;
+
(* Remove the cookie *)
+
Cookeio.remove jar ~clock cookie;
+
(* Delta should contain a removal cookie *)
+
let delta = Cookeio.delta jar in
+
Alcotest.(check int) "delta has 1 removal cookie" 1 (List.length delta);
+
let removal_cookie = List.hd delta in
+
Alcotest.(check string)
+
"removal cookie name" "test"
+
(Cookeio.name removal_cookie);
+
Alcotest.(check string)
+
"removal cookie has empty value" ""
+
(Cookeio.value removal_cookie);
+
(* Check Max-Age is 0 *)
+
match Cookeio.max_age removal_cookie with
+
Alcotest.(check (option int))
+
"removal cookie Max-Age is 0" (Some 0) (Ptime.Span.to_int_s span)
+
| None -> Alcotest.fail "removal cookie should have Max-Age"
+
let test_remove_delta_cookie_removes_it () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?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)
+
(* Remove the cookie *)
+
Cookeio.remove jar ~clock cookie;
+
(* Delta should be empty *)
+
let delta = Cookeio.delta jar in
+
"delta is empty after removing delta cookie" 0 (List.length delta)
+
let test_get_cookies_combines_original_and_delta () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add an original cookie *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"original"
+
~value:"orig_val" ~secure:false ~http_only:false ?expires:None
+
?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)
+
add_original jar original;
+
(* Add a delta cookie *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"delta"
+
~value:"delta_val" ~secure:false ~http_only:false ?expires:None
+
?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)
+
add_cookie jar delta_cookie;
+
(* Get cookies should return both *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "both cookies returned" 2 (List.length cookies);
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
+
Alcotest.(check (list string)) "cookie names" [ "delta"; "original" ] names
+
let test_get_cookies_delta_takes_precedence () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add an original cookie *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"orig_val"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_original jar original;
+
(* Add a delta cookie with the same name/domain/path *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"delta_val"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_cookie jar delta_cookie;
+
(* Get cookies should return only the delta cookie *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "only one cookie returned" 1 (List.length cookies);
+
let cookie = List.hd cookies in
+
Alcotest.(check string)
+
"delta cookie value" "delta_val" (Cookeio.value cookie)
+
let test_get_cookies_excludes_removal_cookies () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add an original cookie *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_original jar original;
+
Cookeio.remove jar ~clock original;
+
(* Get cookies should return nothing *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "no cookies returned" 0 (List.length cookies);
+
(* But delta should have the removal cookie *)
+
let delta = Cookeio.delta jar in
+
Alcotest.(check int) "delta has removal cookie" 1 (List.length delta)
+
let test_delta_returns_only_changed_cookies () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add original cookies *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig1" ~value:"val1"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_original jar original1;
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig2" ~value:"val2"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_original jar original2;
+
(* Add a new delta cookie *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"new_val"
+
~secure:false ~http_only:false ?expires:None ?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)
+
add_cookie jar new_cookie;
+
(* Delta should only contain the new cookie *)
+
let delta = Cookeio.delta jar in
+
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
+
let delta_cookie = List.hd delta in
+
Alcotest.(check string) "delta cookie name" "new" (Cookeio.name delta_cookie)
+
let test_removal_cookie_format () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:true ~http_only:true ?expires:None ~same_site:`Strict
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
add_original jar cookie;
+
(* Remove the cookie *)
+
Cookeio.remove jar ~clock cookie;
+
(* Get the removal cookie *)
+
let delta = Cookeio.delta jar in
+
let removal = List.hd delta in
+
(* Check all properties *)
+
Alcotest.(check string)
+
"removal cookie has empty value" "" (Cookeio.value removal);
+
Alcotest.(check (option int))
+
"removal cookie Max-Age is 0" (Some 0)
+
(Option.bind (Cookeio.max_age removal) Ptime.Span.to_int_s);
+
(* Check expires is in the past *)
+
let now = Ptime.of_float_s 1000.0 |> Option.get in
+
match Cookeio.expires removal with
+
"expires is in the past" true
+
(Ptime.compare exp now < 0)
+
| None -> Alcotest.fail "removal cookie should have expires"
Eio_main.run @@ fun env ->
···
test_cookie_matching env);
+
test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env);
test_case "Cookie expiry with mock clock" `Quick
···
test_parse_set_cookie_with_expires;
test_case "SameSite=None validation" `Quick
test_samesite_none_validation;
+
( "domain_normalization",
+
test_case "Domain normalization" `Quick test_domain_normalization;
+
test_case "Domain matching with normalized domains" `Quick
+
test_case "Max-Age stored separately from Expires" `Quick
+
test_max_age_stored_separately;
+
test_case "Negative Max-Age becomes 0" `Quick
+
test_max_age_negative_becomes_zero;
+
test_case "make_set_cookie_header includes Max-Age" `Quick
+
test_make_set_cookie_header_includes_max_age;
+
test_case "Max-Age round-trip parsing" `Quick test_max_age_round_trip;
+
test_case "add_original doesn't affect delta" `Quick
+
test_add_original_not_in_delta;
+
test_case "add_cookie appears in delta" `Quick
+
test_add_cookie_appears_in_delta;
+
test_case "remove original creates removal cookie" `Quick
+
test_remove_original_creates_removal_cookie;
+
test_case "remove delta cookie just removes it" `Quick
+
test_remove_delta_cookie_removes_it;
+
test_case "get_cookies combines original and delta" `Quick
+
test_get_cookies_combines_original_and_delta;
+
test_case "get_cookies delta takes precedence" `Quick
+
test_get_cookies_delta_takes_precedence;
+
test_case "get_cookies excludes removal cookies" `Quick
+
test_get_cookies_excludes_removal_cookies;
+
test_case "delta returns only changed cookies" `Quick
+
test_delta_returns_only_changed_cookies;
+
test_case "removal cookie format" `Quick test_removal_cookie_format;
+
test_case "HTTP date FMT1 (RFC 1123)" `Quick test_http_date_fmt1;
+
test_case "HTTP date FMT2 (RFC 850)" `Quick test_http_date_fmt2;
+
test_case "HTTP date FMT3 (asctime)" `Quick test_http_date_fmt3;
+
test_case "HTTP date FMT4 (variant)" `Quick test_http_date_fmt4;
+
test_case "Abbreviated year 69-99 becomes 1900+" `Quick
+
test_abbreviated_year_69_to_99;
+
test_case "Abbreviated year 0-68 becomes 2000+" `Quick
+
test_abbreviated_year_0_to_68;
+
test_case "RFC 3339 backward compatibility" `Quick
+
test_rfc3339_still_works;
+
test_case "Invalid date format logs warning" `Quick
+
test_invalid_date_format_logs_warning;
+
test_case "Case-insensitive month parsing" `Quick
+
test_case_insensitive_month_parsing;
+
test_case "Case-insensitive GMT parsing" `Quick
+
test_case_insensitive_gmt_parsing;