···
"only session cookie remains" "session"
(Cookeio.name (List.hd remaining))
+
let test_get_cookies_filters_expired () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add an expired cookie (expired at time 500) *)
+
let expired = Ptime.of_float_s 500.0 |> Option.get in
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expired"
+
~value:"old" ~secure:false ~http_only:false
+
~expires:(`DateTime expired)
+
~creation_time:(Ptime.of_float_s 100.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 100.0 |> Option.get)
+
(* Add a valid cookie (expires at time 2000) *)
+
let valid_time = Ptime.of_float_s 2000.0 |> Option.get in
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"valid"
+
~value:"current" ~secure:false ~http_only:false
+
~expires:(`DateTime valid_time)
+
~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:"sess" ~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
add_cookie jar cookie_expired;
+
add_cookie jar cookie_valid;
+
add_cookie jar cookie_session;
+
(* get_all_cookies returns all including expired (for inspection) *)
+
Alcotest.(check int) "get_all_cookies includes expired" 3
+
(List.length (get_all_cookies jar));
+
(* get_cookies should automatically filter out expired cookies *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "get_cookies filters expired" 2 (List.length cookies);
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
+
Alcotest.(check (list string))
+
"only non-expired cookies returned"
let test_max_age_parsing_with_mock_clock () =
Eio_mock.Backend.run @@ fun () ->
let clock = Eio_mock.Clock.make () in
···
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check string) "cookie name" "session" (Cookeio.name cookie);
Alcotest.(check string) "cookie value" "abc123" (Cookeio.value cookie);
Alcotest.(check bool) "cookie secure" true (Cookeio.secure cookie);
···
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok 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);
···
"invalid cookie rejected" true
+
(Result.is_error cookie_opt);
(* This should be accepted: SameSite=None with Secure *)
let valid_header = "token=abc; SameSite=None; Secure" in
···
"valid cookie accepted" true
+
(Result.is_ok cookie_opt2);
+
let cookie = Result.get_ok cookie_opt2 in
Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie);
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
"domain normalized" "example.com" (Cookeio.domain cookie);
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(* Verify max_age is stored as a Ptime.Span *)
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(* Verify max_age is stored as 0 per RFC 6265 *)
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(* Generate Set-Cookie header from the cookie *)
let set_cookie_header = make_set_cookie_header cookie in
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" set_cookie_header
+
Alcotest.(check bool) "cookie re-parsed" true (Result.is_ok cookie2_opt);
+
let cookie2 = Result.get_ok cookie2_opt in
(* Verify max_age is preserved *)
Alcotest.(check (option int))
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "FMT1 cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(Option.is_some (Cookeio.expires cookie));
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "FMT2 cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(Option.is_some (Cookeio.expires cookie));
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "FMT3 cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(Option.is_some (Cookeio.expires cookie));
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "FMT4 cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(Option.is_some (Cookeio.expires cookie));
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
let cookie = Result.get_ok cookie_opt in
let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
begin match expected with
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header2
+
let cookie2 = Result.get_ok cookie_opt2 in
let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
begin match expected2 with
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header3
+
let cookie3 = Result.get_ok cookie_opt3 in
let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
begin match expected3 with
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
let cookie = Result.get_ok cookie_opt in
let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
begin match expected with
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header2
+
let cookie2 = Result.get_ok cookie_opt2 in
let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
begin match expected2 with
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header3
+
let cookie3 = Result.get_ok cookie_opt3 in
let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
begin match expected3 with
···
"RFC 3339 cookie parsed" true
+
(Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
"RFC 3339 has expiry" true
(Option.is_some (Cookeio.expires cookie));
···
(* Cookie should still be parsed, just without expires *)
"cookie parsed despite invalid date" true
+
(Result.is_ok cookie_opt);
+
let cookie = Result.get_ok 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 *)
···
(description ^ " parsed") true
+
(Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(description ^ " has expiry")
···
(description ^ " parsed") true
+
(Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
(description ^ " has expiry")
···
|> Option.value ~default:Ptime.epoch)
~domain:"widget.com" ~path:"/" "id=123; Partitioned; Secure"
Alcotest.(check bool) "partitioned flag" true (partitioned c);
Alcotest.(check bool) "secure flag" true (secure c)
+
| Error msg -> Alcotest.fail ("Should parse valid Partitioned cookie: " ^ msg)
let test_partitioned_serialization env =
let clock = Eio.Stdenv.clock env in
···
|> Option.value ~default:Ptime.epoch)
~domain:"widget.com" ~path:"/" "id=123; Partitioned"
+
| Error _ -> () (* Expected *)
+
| Ok _ -> Alcotest.fail "Should reject Partitioned without Secure"
(* Priority 2.2: Expiration Variants *)
···
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" "id=123; Expires=0"
Alcotest.(check (option expiration_testable))
"expires=0 is session" (Some `Session) (expires c)
+
| Error msg -> Alcotest.fail ("Should parse Expires=0: " ^ msg)
let test_serialize_expiration_variants env =
let clock = Eio.Stdenv.clock env in
···
let test_quoted_cookie_values env =
let clock = Eio.Stdenv.clock env in
+
(* Test valid RFC 6265 cookie values:
+
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
+
Valid cases have either no quotes or properly paired DQUOTE wrapper *)
+
("name=value", "value", "value"); (* No quotes *)
+
("name=\"value\"", "\"value\"", "value"); (* Properly quoted *)
+
("name=\"\"", "\"\"", ""); (* Empty quoted value *)
···
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" input
(Printf.sprintf "raw value for %s" input)
(Printf.sprintf "trimmed value for %s" input)
expected_trimmed (value_trimmed c)
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ input ^ ": " ^ msg))
+
(* Test invalid RFC 6265 cookie values are rejected *)
+
"name=\"partial"; (* Opening quote without closing *)
+
"name=\"val\"\""; (* Embedded quote *)
+
"name=val\""; (* Trailing quote without opening *)
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" input
+
| Error _ -> () (* Expected - invalid values are rejected *)
+
(Printf.sprintf "Should reject invalid value: %s" input))
let test_trimmed_value_not_used_for_equality env =
let clock = Eio.Stdenv.clock env in
···
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" "name=\"value\""
···
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" "name=value"
(* Different raw values *)
"different raw values" false
···
(* Same trimmed values *)
"same trimmed values" (value_trimmed c1) (value_trimmed c2)
+
| Error msg -> Alcotest.fail ("Parse failed for unquoted: " ^ msg)
+
| Error msg -> Alcotest.fail ("Parse failed for quoted: " ^ msg)
(* Priority 2.4: Cookie Header Parsing *)
let test_cookie_header_parsing_basic env =
let clock = Eio.Stdenv.clock env in
Ptime.of_float_s (Eio.Time.now clock)
···
~domain:"ex.com" ~path:"/" "session=abc123; theme=dark; lang=en"
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
+
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
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/app" "session=xyz"
(* 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);
···
Alcotest.(check (option span_testable)) "no max_age" None (max_age c);
Alcotest.(check (option same_site_testable))
"no same_site" None (same_site c)
+
| Ok _ -> Alcotest.fail "Should parse single cookie"
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
let test_cookie_header_edge_cases env =
let clock = Eio.Stdenv.clock env in
let test input expected_count description =
Ptime.of_float_s (Eio.Time.now clock)
|> Option.value ~default:Ptime.epoch)
~domain:"ex.com" ~path:"/" input
+
Alcotest.(check int) description expected_count (List.length cookies)
+
Alcotest.fail (description ^ " failed: " ^ msg)
test "" 0 "empty string";
···
let test_cookie_header_with_errors env =
let clock = Eio.Stdenv.clock env in
+
(* Invalid cookie (empty name) should cause entire parse to fail *)
Ptime.of_float_s (Eio.Time.now clock)
···
~domain:"ex.com" ~path:"/" "valid=1;=noname;valid2=2"
+
(* Error should have descriptive message about the invalid cookie *)
let contains_substring s sub =
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
let has_name = contains_substring msg "name" in
let has_empty = contains_substring msg "empty" in
"error mentions name or empty" true (has_name || has_empty)
+
| Ok _ -> Alcotest.fail "Expected error for empty cookie name"
(* Max-Age and Expires Interaction *)
···
~domain:"ex.com" ~path:"/"
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT"
(* Both should be stored *)
begin match max_age c with
···
| Some (`DateTime _) -> ()
| _ -> Alcotest.fail "expires should be parsed"
+
| Error msg -> Alcotest.fail ("Should parse cookie with both attributes: " ^ msg)
(* ============================================================================ *)
(* Host-Only Flag Tests (RFC 6265 Section 5.3) *)
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
Alcotest.(check string) "domain is request host" "example.com" (Cookeio.domain cookie)
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
Alcotest.(check string) "domain is attribute value" "example.com" (Cookeio.domain cookie)
···
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" header
+
Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
+
let cookie = Result.get_ok cookie_opt in
Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
Alcotest.(check string) "domain normalized" "example.com" (Cookeio.domain cookie)
···
Eio_mock.Clock.set_time clock 1000.0;
(* Cookies from Cookie header should have host_only=true *)
Ptime.of_float_s (Eio.Time.now clock)
|> Option.value ~default:Ptime.epoch)
~domain:"example.com" ~path:"/" "session=abc; theme=dark"
+
| Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
+
Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies);
+
("host_only is true for " ^ Cookeio.name c)
+
true (Cookeio.host_only c)
let test_host_only_mozilla_format_round_trip () =
Eio_mock.Backend.run @@ fun () ->
···
Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
+
(* ============================================================================ *)
+
(* Cookie Ordering Tests (RFC 6265 Section 5.4, Step 2) *)
+
(* ============================================================================ *)
+
let test_cookie_ordering_by_path_length () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add cookies with different path lengths, but same creation time *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"short" ~value:"v1"
+
~secure:false ~http_only:false
+
~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:"/foo" ~name:"medium" ~value:"v2"
+
~secure:false ~http_only:false
+
~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:"/foo/bar" ~name:"long" ~value:"v3"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
(* Add in random order *)
+
add_cookie jar cookie_short;
+
add_cookie jar cookie_long;
+
add_cookie jar cookie_medium;
+
(* Get cookies for path /foo/bar/baz - all three should match *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
+
(* Verify order: longest path first *)
+
let names = List.map Cookeio.name cookies in
+
Alcotest.(check (list string))
+
"cookies ordered by path length (longest first)"
+
[ "long"; "medium"; "short" ]
+
let test_cookie_ordering_by_creation_time () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 2000.0;
+
(* Add cookies with same path but different creation times *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"old" ~value:"v2"
+
~secure:false ~http_only:false
+
~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:"middle" ~value:"v3"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1200.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1200.0 |> Option.get) ()
+
(* Add in random order *)
+
add_cookie jar cookie_new;
+
add_cookie jar cookie_old;
+
add_cookie jar cookie_middle;
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
+
(* Verify order: earlier creation time first (for same path length) *)
+
let names = List.map Cookeio.name cookies in
+
Alcotest.(check (list string))
+
"cookies ordered by creation time (earliest first)"
+
[ "old"; "middle"; "new" ]
+
let test_cookie_ordering_combined () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 2000.0;
+
(* Mix of different paths and creation times *)
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"a" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"b" ~value:"v2"
+
~secure:false ~http_only:false
+
~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:"c" ~value:"v3"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 500.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
+
add_cookie jar cookie_a;
+
add_cookie jar cookie_c;
+
add_cookie jar cookie_b;
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
+
Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
+
(* /foo cookies (length 4) should come before / cookie (length 1)
+
Within /foo, earlier creation time (b=1000) should come before (a=1500) *)
+
let names = List.map Cookeio.name cookies in
+
Alcotest.(check (list string))
+
"cookies ordered by path length then creation time"
+
(* ============================================================================ *)
+
(* Creation Time Preservation Tests (RFC 6265 Section 5.3, Step 11.3) *)
+
(* ============================================================================ *)
+
let test_creation_time_preserved_on_update () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add initial cookie with creation_time=500 *)
+
let original_creation = Ptime.of_float_s 500.0 |> Option.get in
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:original_creation
+
~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
+
add_cookie jar cookie_v1;
+
(* Update the cookie with a new value (creation_time=1000) *)
+
Eio_mock.Clock.set_time clock 1500.0;
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v2"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
+
add_cookie jar cookie_v2;
+
(* Get the cookie and verify creation_time was preserved *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "still one cookie" 1 (List.length cookies);
+
let cookie = List.hd cookies in
+
Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
+
(* Creation time should be preserved from original cookie *)
+
Ptime.to_float_s (Cookeio.creation_time cookie)
+
Alcotest.(check (float 0.001))
+
"creation_time preserved from original"
+
let test_creation_time_preserved_add_original () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add initial original cookie *)
+
let original_creation = Ptime.of_float_s 100.0 |> Option.get in
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:original_creation
+
~last_access:(Ptime.of_float_s 100.0 |> Option.get) ()
+
add_original jar cookie_v1;
+
(* Replace with new original cookie *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v2"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
add_original jar cookie_v2;
+
let cookies = get_all_cookies jar in
+
Alcotest.(check int) "still one cookie" 1 (List.length cookies);
+
let cookie = List.hd cookies in
+
Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
+
(* Creation time should be preserved *)
+
Ptime.to_float_s (Cookeio.creation_time cookie)
+
Alcotest.(check (float 0.001))
+
"creation_time preserved in add_original"
+
let test_creation_time_new_cookie () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add a new cookie (no existing cookie to preserve from) *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
let cookie = List.hd cookies in
+
(* New cookie should keep its own creation time *)
+
Ptime.to_float_s (Cookeio.creation_time cookie)
+
Alcotest.(check (float 0.001))
+
"new cookie keeps its creation_time"
+
(* ============================================================================ *)
+
(* IP Address Domain Matching Tests (RFC 6265 Section 5.1.3) *)
+
(* ============================================================================ *)
+
let test_ipv4_exact_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
(* IPv4 cookie should match exact IP *)
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "IPv4 exact match" 1 (List.length cookies)
+
let test_ipv4_no_suffix_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Cookie for 168.1.1 - this should NOT match requests to 192.168.1.1
+
even though "192.168.1.1" ends with ".168.1.1" *)
+
Cookeio.make ~domain:"168.1.1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
(* Should NOT match - IP addresses don't do suffix matching *)
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "IPv4 no suffix match" 0 (List.length cookies)
+
let test_ipv4_different_ip () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
(* Different IP should not match *)
+
get_cookies jar ~clock ~domain:"192.168.1.2" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "different IPv4 no match" 0 (List.length cookies)
+
let test_ipv6_exact_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"::1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
(* IPv6 loopback should match exactly *)
+
get_cookies jar ~clock ~domain:"::1" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "IPv6 exact match" 1 (List.length cookies)
+
let test_ipv6_full_format () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
Cookeio.make ~domain:"2001:db8::1" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
(* IPv6 should match exactly *)
+
get_cookies jar ~clock ~domain:"2001:db8::1" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "IPv6 full format match" 1 (List.length cookies);
+
(* Different IPv6 should not match *)
+
get_cookies jar ~clock ~domain:"2001:db8::2" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "different IPv6 no match" 0 (List.length cookies2)
+
let test_ip_vs_hostname () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add a hostname cookie with host_only=false (domain cookie) *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostname" ~value:"h1"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
add_cookie jar hostname_cookie;
+
(* Add an IP cookie with host_only=false *)
+
Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"ip" ~value:"i1"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
add_cookie jar ip_cookie;
+
(* Hostname request should match hostname cookie and subdomains *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "hostname matches hostname cookie" 1 (List.length cookies1);
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "subdomain matches hostname cookie" 1 (List.length cookies2);
+
(* IP request should only match IP cookie exactly *)
+
get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "IP matches IP cookie" 1 (List.length cookies3);
+
Alcotest.(check string) "IP cookie is returned" "ip" (Cookeio.name (List.hd cookies3))
+
(* ============================================================================ *)
+
(* RFC 6265 Validation Tests *)
+
(* ============================================================================ *)
+
let test_validate_cookie_name_valid () =
+
(* Valid token characters per RFC 2616 *)
+
let valid_names = ["session"; "SID"; "my-cookie"; "COOKIE_123"; "abc.def"] in
+
match Cookeio.Validate.cookie_name name with
+
Alcotest.fail (Printf.sprintf "Name %S should be valid: %s" name msg))
+
let test_validate_cookie_name_invalid () =
+
(* Invalid: control chars, separators, spaces *)
+
("my cookie", "space");
+
("cookie=value", "equals");
+
("my;cookie", "semicolon");
+
("(cookie)", "parens");
+
List.iter (fun (name, reason) ->
+
match Cookeio.Validate.cookie_name name with
+
| Error _ -> () (* Expected *)
+
(Printf.sprintf "Name %S (%s) should be invalid" name reason))
+
let test_validate_cookie_value_valid () =
+
(* Valid cookie-octets or quoted values *)
+
let valid_values = ["abc123"; "value!#$%&'()*+-./"; "\"quoted\""; ""] in
+
List.iter (fun value ->
+
match Cookeio.Validate.cookie_value value with
+
Alcotest.fail (Printf.sprintf "Value %S should be valid: %s" value msg))
+
let test_validate_cookie_value_invalid () =
+
(* Invalid: space, comma, semicolon, backslash, unmatched quotes *)
+
("with space", "space");
+
("with,comma", "comma");
+
("with;semi", "semicolon");
+
("back\\slash", "backslash");
+
("\"unmatched", "unmatched opening quote");
+
("unmatched\"", "unmatched closing quote");
+
List.iter (fun (value, reason) ->
+
match Cookeio.Validate.cookie_value value with
+
| Error _ -> () (* Expected *)
+
(Printf.sprintf "Value %S (%s) should be invalid" value reason))
+
let test_validate_domain_valid () =
+
(* Valid domain names and IP addresses *)
+
["example.com"; "sub.example.com"; ".example.com"; "192.168.1.1"; "::1"]
+
List.iter (fun domain ->
+
match Cookeio.Validate.domain_value domain with
+
Alcotest.fail (Printf.sprintf "Domain %S should be valid: %s" domain msg))
+
let test_validate_domain_invalid () =
+
(* Invalid domain names - only test cases that domain-name library rejects.
+
Note: domain-name library has specific rules that may differ from what
+
we might expect from the RFC. *)
+
(* Note: "-invalid.com" and "invalid-.com" are valid per domain-name library *)
+
List.iter (fun (domain, reason) ->
+
match Cookeio.Validate.domain_value domain with
+
| Error _ -> () (* Expected *)
+
(Printf.sprintf "Domain %S (%s) should be invalid" domain reason))
+
let test_validate_path_valid () =
+
let valid_paths = ["/"; "/path"; "/path/to/resource"; "/path?query"] in
+
match Cookeio.Validate.path_value path with
+
Alcotest.fail (Printf.sprintf "Path %S should be valid: %s" path msg))
+
let test_validate_path_invalid () =
+
("/path;bad", "semicolon");
+
("/path\x00bad", "control char");
+
List.iter (fun (path, reason) ->
+
match Cookeio.Validate.path_value path with
+
| Error _ -> () (* Expected *)
+
(Printf.sprintf "Path %S (%s) should be invalid" path reason))
+
let test_duplicate_cookie_detection () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Duplicate cookie names should be rejected *)
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" "session=abc; theme=dark; session=xyz"
+
(* Should mention duplicate *)
+
let contains_dup = String.lowercase_ascii msg |> fun s ->
+
try let _ = Str.search_forward (Str.regexp_string "duplicate") s 0 in true
+
with Not_found -> false
+
Alcotest.(check bool) "error mentions duplicate" true contains_dup
+
| Ok _ -> Alcotest.fail "Should reject duplicate cookie names"
+
let test_validation_error_messages () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Test that error messages are descriptive *)
+
("=noname", "Cookie name is empty");
+
("bad cookie=value", "invalid characters");
+
("name=val ue", "invalid characters");
+
List.iter (fun (header, expected_substring) ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
let _ = Str.search_forward
+
(Str.regexp_string expected_substring) msg 0 in
+
with Not_found -> false
+
(Printf.sprintf "error for %S mentions %S" header expected_substring)
+
Alcotest.fail (Printf.sprintf "Should reject %S" header))
+
(* ============================================================================ *)
+
(* Public Suffix Validation Tests (RFC 6265 Section 5.3, Step 5) *)
+
(* ============================================================================ *)
+
let test_public_suffix_rejection () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Setting a cookie for a public suffix (TLD) should be rejected *)
+
(* (request_domain, cookie_domain, description) *)
+
("www.example.com", "com", "TLD .com");
+
("www.example.co.uk", "co.uk", "ccTLD .co.uk");
+
("foo.bar.github.io", "github.io", "private domain github.io");
+
(fun (request_domain, cookie_domain, description) ->
+
let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:request_domain ~path:"/" header
+
(* Should mention public suffix *)
+
String.lowercase_ascii msg |> fun s ->
+
let _ = Str.search_forward (Str.regexp_string "public suffix") s 0 in
+
with Not_found -> false
+
(Printf.sprintf "%s: error mentions public suffix" description)
+
(Printf.sprintf "Should reject cookie for %s" description))
+
let test_public_suffix_allowed_when_exact_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* If request host exactly matches the public suffix domain, allow it.
+
This is rare but possible for private domains like blogspot.com *)
+
let header = "session=abc; Domain=.blogspot.com" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"blogspot.com" ~path:"/" header
+
"exact match allows public suffix" true
+
let test_non_public_suffix_allowed () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Normal domain (not a public suffix) should be allowed *)
+
("www.example.com", "example.com", "registrable domain");
+
("sub.example.com", "example.com", "parent of subdomain");
+
("www.example.co.uk", "example.co.uk", "registrable domain under ccTLD");
+
(fun (request_domain, cookie_domain, description) ->
+
let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:request_domain ~path:"/" header
+
Alcotest.(check string)
+
(Printf.sprintf "%s: domain correct" description)
+
cookie_domain (Cookeio.domain cookie)
+
(Printf.sprintf "%s should be allowed: %s" description msg))
+
let test_public_suffix_no_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Cookie without Domain attribute should always be allowed (host-only) *)
+
let header = "session=abc; Secure; HttpOnly" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"www.example.com" ~path:"/" header
+
Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
+
Alcotest.(check string)
+
"domain is request domain" "www.example.com"
+
(Cookeio.domain cookie)
+
| Error msg -> Alcotest.fail ("Should allow host-only cookie: " ^ msg)
+
let test_public_suffix_ip_address_bypass () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* IP addresses should bypass PSL check *)
+
let header = "session=abc; Domain=192.168.1.1" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"192.168.1.1" ~path:"/" header
+
"IP address bypasses PSL" true
+
let test_public_suffix_case_insensitive () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Public suffix check should be case-insensitive *)
+
let header = "session=abc; Domain=.COM" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"www.example.COM" ~path:"/" header
+
"uppercase TLD still rejected" true
+
(Result.is_error result)
Eio_main.run @@ fun env ->
···
test_case "Cookie expiry with mock clock" `Quick
test_cookie_expiry_with_mock_clock;
+
test_case "get_cookies filters expired cookies" `Quick
+
test_get_cookies_filters_expired;
test_case "Max-Age parsing with mock clock" `Quick
test_max_age_parsing_with_mock_clock;
test_case "Last access time with mock clock" `Quick
···
test_path_matching_no_false_prefix;
test_case "root path matches all" `Quick test_path_matching_root;
test_case "path no match" `Quick test_path_matching_no_match;
+
( "ip_address_matching",
+
test_case "IPv4 exact match" `Quick test_ipv4_exact_match;
+
test_case "IPv4 no suffix match" `Quick test_ipv4_no_suffix_match;
+
test_case "IPv4 different IP no match" `Quick test_ipv4_different_ip;
+
test_case "IPv6 exact match" `Quick test_ipv6_exact_match;
+
test_case "IPv6 full format" `Quick test_ipv6_full_format;
+
test_case "IP vs hostname behavior" `Quick test_ip_vs_hostname;
+
( "rfc6265_validation",
+
test_case "valid cookie names" `Quick test_validate_cookie_name_valid;
+
test_case "invalid cookie names" `Quick test_validate_cookie_name_invalid;
+
test_case "valid cookie values" `Quick test_validate_cookie_value_valid;
+
test_case "invalid cookie values" `Quick test_validate_cookie_value_invalid;
+
test_case "valid domain values" `Quick test_validate_domain_valid;
+
test_case "invalid domain values" `Quick test_validate_domain_invalid;
+
test_case "valid path values" `Quick test_validate_path_valid;
+
test_case "invalid path values" `Quick test_validate_path_invalid;
+
test_case "duplicate cookie detection" `Quick test_duplicate_cookie_detection;
+
test_case "validation error messages" `Quick test_validation_error_messages;
+
test_case "ordering by path length" `Quick
+
test_cookie_ordering_by_path_length;
+
test_case "ordering by creation time" `Quick
+
test_cookie_ordering_by_creation_time;
+
test_case "ordering combined" `Quick test_cookie_ordering_combined;
+
( "creation_time_preservation",
+
test_case "preserved on update" `Quick
+
test_creation_time_preserved_on_update;
+
test_case "preserved in add_original" `Quick
+
test_creation_time_preserved_add_original;
+
test_case "new cookie keeps time" `Quick test_creation_time_new_cookie;
+
( "public_suffix_validation",
+
test_case "reject public suffix domains" `Quick
+
test_public_suffix_rejection;
+
test_case "allow exact match on public suffix" `Quick
+
test_public_suffix_allowed_when_exact_match;
+
test_case "allow non-public-suffix domains" `Quick
+
test_non_public_suffix_allowed;
+
test_case "no Domain attribute bypasses PSL" `Quick
+
test_public_suffix_no_domain_attribute;
+
test_case "IP address bypasses PSL" `Quick
+
test_public_suffix_ip_address_bypass;
+
test_case "case insensitive check" `Quick
+
test_public_suffix_case_insensitive;