···
| None -> Alcotest.fail "Should parse cookie with both attributes"
+
(* ============================================================================ *)
+
(* Host-Only Flag Tests (RFC 6265 Section 5.3) *)
+
(* ============================================================================ *)
+
let test_host_only_without_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 have host_only=true *)
+
let header = "session=abc123; Secure; HttpOnly" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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 bool) "host_only is true" true (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain is request host" "example.com" (Cookeio.domain cookie)
+
let test_host_only_with_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Cookie with Domain attribute should have host_only=false *)
+
let header = "session=abc123; Domain=example.com; Secure" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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 bool) "host_only is false" false (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain is attribute value" "example.com" (Cookeio.domain cookie)
+
let test_host_only_with_dotted_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Cookie with .domain should have host_only=false and normalized domain *)
+
let header = "session=abc123; Domain=.example.com" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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 bool) "host_only is false" false (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain normalized" "example.com" (Cookeio.domain cookie)
+
let test_host_only_domain_matching () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add a host-only cookie (no Domain attribute) *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"host_only" ~value:"val1"
+
~secure:false ~http_only:false ~host_only:true
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
add_cookie jar host_only_cookie;
+
(* Add a domain cookie (with Domain attribute) *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"val2"
+
~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 domain_cookie;
+
(* Both cookies should match exact domain *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "both match exact domain" 2 (List.length cookies_exact);
+
(* Only domain cookie should match subdomain *)
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "only domain cookie matches subdomain" 1 (List.length cookies_sub);
+
let sub_cookie = List.hd cookies_sub in
+
Alcotest.(check string) "subdomain match is domain cookie" "domain" (Cookeio.name sub_cookie)
+
let test_host_only_cookie_header_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
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"
+
let cookies = List.filter_map Result.to_option results in
+
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 () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
(* Add host-only cookie *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostonly" ~value:"v1"
+
~secure:false ~http_only:false ~host_only:true
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
add_cookie jar host_only;
+
(* Add domain cookie *)
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"v2"
+
~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 domain_cookie;
+
(* Round trip through Mozilla format *)
+
let mozilla = to_mozilla_format jar in
+
let jar2 = from_mozilla_format ~clock mozilla in
+
let cookies = get_all_cookies jar2 in
+
Alcotest.(check int) "2 cookies after round trip" 2 (List.length cookies);
+
let find name_val = List.find (fun c -> Cookeio.name c = name_val) cookies in
+
Alcotest.(check bool) "hostonly preserved" true (Cookeio.host_only (find "hostonly"));
+
Alcotest.(check bool) "domain preserved" false (Cookeio.host_only (find "domain"))
+
(* ============================================================================ *)
+
(* Path Matching Tests (RFC 6265 Section 5.1.4) *)
+
(* ============================================================================ *)
+
let test_path_matching_identical () =
+
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:"/foo" ~name:"test" ~value:"val"
+
~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) ()
+
(* Identical path should match *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
Alcotest.(check int) "identical path matches" 1 (List.length cookies)
+
let test_path_matching_with_trailing_slash () =
+
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:"/foo/" ~name:"test" ~value:"val"
+
~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) ()
+
(* Cookie path /foo/ should match /foo/bar *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
+
Alcotest.(check int) "/foo/ matches /foo/bar" 1 (List.length cookies);
+
(* Cookie path /foo/ should match /foo/ *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
+
Alcotest.(check int) "/foo/ matches /foo/" 1 (List.length cookies2)
+
let test_path_matching_prefix_with_slash () =
+
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:"/foo" ~name:"test" ~value:"val"
+
~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) ()
+
(* Cookie path /foo should match /foo/bar (next char is /) *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
+
Alcotest.(check int) "/foo matches /foo/bar" 1 (List.length cookies);
+
(* Cookie path /foo should match /foo/ *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
+
Alcotest.(check int) "/foo matches /foo/" 1 (List.length cookies2)
+
let test_path_matching_no_false_prefix () =
+
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:"/foo" ~name:"test" ~value:"val"
+
~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) ()
+
(* Cookie path /foo should NOT match /foobar (no / separator) *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foobar" ~is_secure:false
+
Alcotest.(check int) "/foo does NOT match /foobar" 0 (List.length cookies);
+
(* Cookie path /foo should NOT match /foob *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foob" ~is_secure:false
+
Alcotest.(check int) "/foo does NOT match /foob" 0 (List.length cookies2)
+
let test_path_matching_root () =
+
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:"val"
+
~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) ()
+
(* Root path should match everything *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "/ matches /" 1 (List.length cookies1);
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
Alcotest.(check int) "/ matches /foo" 1 (List.length cookies2);
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
+
Alcotest.(check int) "/ matches /foo/bar/baz" 1 (List.length cookies3)
+
let test_path_matching_no_match () =
+
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:"/foo/bar" ~name:"test" ~value:"val"
+
~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) ()
+
(* Cookie path /foo/bar should NOT match /foo *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
Alcotest.(check int) "/foo/bar does NOT match /foo" 0 (List.length cookies);
+
(* Cookie path /foo/bar should NOT match / *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
Alcotest.(check int) "/foo/bar does NOT match /" 0 (List.length cookies2);
+
(* Cookie path /foo/bar should NOT match /baz *)
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/baz" ~is_secure:false
+
Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
Eio_main.run @@ fun env ->
···
test_max_age_and_expires_both_present env);
test_case "parse both" `Quick (fun () ->
test_parse_max_age_and_expires env);
+
test_case "host_only without Domain attribute" `Quick
+
test_host_only_without_domain_attribute;
+
test_case "host_only with Domain attribute" `Quick
+
test_host_only_with_domain_attribute;
+
test_case "host_only with dotted Domain attribute" `Quick
+
test_host_only_with_dotted_domain_attribute;
+
test_case "host_only domain matching" `Quick
+
test_host_only_domain_matching;
+
test_case "host_only Cookie header parsing" `Quick
+
test_host_only_cookie_header_parsing;
+
test_case "host_only Mozilla format round trip" `Quick
+
test_host_only_mozilla_format_round_trip;
+
test_case "identical path" `Quick test_path_matching_identical;
+
test_case "path with trailing slash" `Quick
+
test_path_matching_with_trailing_slash;
+
test_case "prefix with slash separator" `Quick
+
test_path_matching_prefix_with_slash;
+
test_case "no false prefix match" `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;