···
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 (fun ppf e ->
| `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 (fun ppf -> function
+
| `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 =
···
begin match expected_expiry with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie3)
| None -> Alcotest.fail "Expected expiry time for cookie-3"
···
begin match expected_expiry with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie4)
| None -> Alcotest.fail "Expected expiry time for cookie-4"
···
begin match expected_expiry with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie5)
| None -> Alcotest.fail "Expected expiry time for cookie-5"
···
begin match expected_expiry with
Alcotest.(check (option expiration_testable))
+
"file cookie-5 expires"
+
(Cookeio.expires cookie5)
| None -> Alcotest.fail "Expected expiry time for cookie-5"
···
begin match Ptime.of_float_s 1257894000.0 with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie2)
| None -> Alcotest.fail "Expected expiry time"
···
(* Parse a Set-Cookie header with Max-Age *)
let header = "session=abc123; Max-Age=3600; 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);
···
begin match expected_expiry with
Alcotest.(check (option expiration_testable))
+
"expires set from max-age"
+
(Cookeio.expires cookie)
| None -> Alcotest.fail "Expected expiry time"
···
"id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com"
+
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);
···
match expected_expiry with
Alcotest.(check (option expiration_testable))
+
"expires matches parsed value"
+
(Some (`DateTime time))
| Error _ -> Alcotest.fail "Failed to parse expected expiry time"
···
(* This should be rejected: SameSite=None without Secure *)
let invalid_header = "token=abc; SameSite=None" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" invalid_header
···
(* This should be accepted: SameSite=None with Secure *)
let valid_header = "token=abc; SameSite=None; Secure" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" valid_header
···
(* Test parsing ".example.com" stores as "example.com" *)
let header = "test=value; 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
···
(* Parse a Set-Cookie header with Max-Age *)
let header = "session=abc123; Max-Age=3600" 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);
···
begin match expected_expiry with
Alcotest.(check (option expiration_testable))
+
"expires computed from max-age"
| None -> Alcotest.fail "Expected expiry time"
···
(* Parse a Set-Cookie header with negative Max-Age *)
let header = "session=abc123; Max-Age=-100" 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);
···
begin match expected_expiry with
Alcotest.(check (option expiration_testable))
+
"expires computed with 0 seconds"
| None -> Alcotest.fail "Expected expiry time"
···
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)
···
(* Parse a cookie with Max-Age *)
let header = "session=xyz; Max-Age=7200; 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
···
Eio_mock.Clock.set_time clock 5000.0;
(* Reset clock to same time *)
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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
···
(* 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
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
Alcotest.(check bool) "FMT1 cookie parsed" true (Option.is_some cookie_opt);
···
begin match expected with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie)
| None -> Alcotest.fail "Expected expiry time for FMT1"
···
(* 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
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
Alcotest.(check bool) "FMT2 cookie parsed" true (Option.is_some cookie_opt);
···
begin match expected with
Alcotest.(check (option expiration_testable))
+
"FMT2 expiry correct with year normalization"
| None -> Alcotest.fail "Expected expiry time for FMT2"
···
(* Test FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
let header = "session=abc; Expires=Wed Oct 21 07:28:00 2015" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
Alcotest.(check bool) "FMT3 cookie parsed" true (Option.is_some cookie_opt);
···
begin match expected with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie)
| None -> Alcotest.fail "Expected expiry time for FMT3"
···
(* 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
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
Alcotest.(check bool) "FMT4 cookie parsed" true (Option.is_some cookie_opt);
···
begin match expected with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie)
| None -> Alcotest.fail "Expected expiry time for FMT4"
···
(* Year 95 should become 1995 *)
let header = "session=abc; Expires=Wed, 21-Oct-95 07:28:00 GMT" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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
begin match expected with
Alcotest.(check (option expiration_testable))
+
(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
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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
begin match expected2 with
Alcotest.(check (option expiration_testable))
+
(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
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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
begin match expected3 with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie3)
| None -> Alcotest.fail "Expected expiry time for year 99"
···
(* Year 25 should become 2025 *)
let header = "session=abc; Expires=Wed, 21-Oct-25 07:28:00 GMT" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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
begin match expected with
Alcotest.(check (option expiration_testable))
+
(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
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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
begin match expected2 with
Alcotest.(check (option expiration_testable))
+
(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
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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
begin match expected3 with
Alcotest.(check (option expiration_testable))
+
(Cookeio.expires cookie3)
| None -> Alcotest.fail "Expected expiry time for year 68"
···
(* Ensure RFC 3339 format still works for backward compatibility *)
let header = "session=abc; Expires=2025-10-21T07:28:00Z" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
"RFC 3339 cookie parsed" true
···
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 () =
···
(* Invalid date format should log a warning but still parse the cookie *)
let header = "session=abc; Expires=InvalidDate" in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
(* Cookie should still be parsed, just without expires *)
···
(fun (header, description) ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
(description ^ " parsed") true
···
(fun (header, description) ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
(description ^ " parsed") true
···
let test_partitioned_parsing env =
let clock = Eio.Stdenv.clock env in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> 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)
···
let test_partitioned_serialization env =
let clock = Eio.Stdenv.clock env in
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
+
make ~domain:"widget.com" ~path:"/" ~name:"id" ~value:"123" ~secure:true
+
~partitioned:true ~creation_time:now ~last_access:now ()
let header = make_set_cookie_header cookie in
let contains_substring s sub =
···
let clock = Eio.Stdenv.clock env in
(* Partitioned without Secure should be rejected *)
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"widget.com" ~path:"/" "id=123; Partitioned"
| None -> () (* Expected *)
| Some _ -> Alcotest.fail "Should reject Partitioned without Secure"
···
let test_expiration_variants env =
let clock = Eio.Stdenv.clock env in
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
let make_base ~name ?expires () =
+
make ~domain:"ex.com" ~path:"/" ~name ~value:"v" ?expires ~creation_time:now
let c1 = make_base ~name:"no_expiry" () in
+
Alcotest.(check (option expiration_testable))
+
"no expiration" None (expires c1);
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 clock = Eio.Stdenv.clock env in
(* Expires=0 should parse as Session *)
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> 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)
| None -> Alcotest.fail "Should parse Expires=0"
let test_serialize_expiration_variants env =
let clock = Eio.Stdenv.clock env in
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
let contains_substring s sub =
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
···
(* Session cookie serialization *)
+
make ~domain:"ex.com" ~path:"/" ~name:"s" ~value:"v" ~expires:`Session
+
~creation_time:now ~last_access:now ()
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
+
make ~domain:"ex.com" ~path:"/" ~name:"p" ~value:"v"
+
~expires:(`DateTime future) ~creation_time:now ~last_access:now ()
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
···
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=\"\"", "\"\"", "");
+
(fun (input, expected_raw, expected_trimmed) ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" input
+
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 (value_trimmed c)
+
| None -> Alcotest.fail ("Parse failed: " ^ input))
let test_trimmed_value_not_used_for_equality env =
let clock = Eio.Stdenv.clock env in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "name=\"value\""
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "name=value"
(* Different raw values *)
+
"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
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "session=abc123; theme=dark; lang=en"
let cookies = List.filter_map Result.to_option results in
Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
···
let test_cookie_header_defaults env =
let clock = Eio.Stdenv.clock env in
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> 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 bool) "partitioned default" false (partitioned c);
(* Optional attributes default to None *)
+
Alcotest.(check (option expiration_testable))
+
"no expiration" None (expires 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)
| _ -> 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 =
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" input
let cookies = List.filter_map Result.to_option results in
Alcotest.(check int) description expected_count (List.length cookies)
···
let clock = Eio.Stdenv.clock env in
(* Mix of valid and invalid cookies *)
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"ex.com" ~path:"/" "valid=1;=noname;valid2=2"
Alcotest.(check int) "total results" 3 (List.length results);
···
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"
···
let test_max_age_and_expires_both_present env =
let clock = Eio.Stdenv.clock env in
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
let future = Ptime.add_span now (Ptime.Span.of_int_s 7200) |> Option.get in
(* Create cookie with both *)
+
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 ()
(* Both should be present *)
begin match max_age cookie with
+
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"
···
let clock = Eio.Stdenv.clock env in
(* Parse Set-Cookie with both attributes *)
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~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
+
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"