OCaml HTTP cookie handling library with support for Eio-based storage jars

add CHIPS partition functionality and tests

Changed files
+785 -144
lib
test
+156 -49
lib/cookeio.ml
···
module Log = (val Logs.src_log src : Logs.LOG)
-
type same_site = [ `Strict | `Lax | `None ]
-
(** Cookie same-site policy *)
type t = {
domain : string;
···
value : string;
secure : bool;
http_only : bool;
-
expires : Ptime.t option;
max_age : Ptime.Span.t option;
-
same_site : same_site option;
creation_time : Ptime.t;
last_access : Ptime.t;
}
···
let path cookie = cookie.path
let name cookie = cookie.name
let value cookie = cookie.value
let secure cookie = cookie.secure
let http_only cookie = cookie.http_only
let expires cookie = cookie.expires
let max_age cookie = cookie.max_age
let same_site cookie = cookie.same_site
···
let last_access cookie = cookie.last_access
let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
-
?expires ?max_age ?same_site ~creation_time ~last_access () =
{
domain;
path;
···
value;
secure;
http_only;
expires;
max_age;
same_site;
···
(** {1 HTTP Date Parsing} *)
let is_expired cookie clock =
match cookie.expires with
-
| None -> false (* Session cookie *)
-
| Some exp_time ->
let now =
Ptime.of_float_s (Eio.Time.now clock)
|> Option.value ~default:Ptime.epoch
···
mutable path : string option;
mutable secure : bool;
mutable http_only : bool;
-
mutable expires : Ptime.t option;
mutable max_age : Ptime.Span.t option;
-
mutable same_site : same_site option;
}
(** Accumulated attributes from parsing Set-Cookie header *)
···
path = None;
secure = false;
http_only = false;
expires = None;
max_age = None;
same_site = None;
···
| "domain" -> attrs.domain <- Some (normalize_domain attr_value)
| "path" -> attrs.path <- Some attr_value
| "expires" -> (
-
match Ptime.of_rfc3339 attr_value with
-
| Ok (time, _, _) -> attrs.expires <- Some time
-
| Error (`RFC3339 (_, err)) -> (
-
(* Try HTTP date format as fallback *)
-
match DateParser.parse_http_date attr_value with
-
| Some time -> attrs.expires <- Some time
-
| None ->
-
Log.warn (fun m ->
-
m "Failed to parse expires attribute '%s': %a" attr_value
-
Ptime.pp_rfc3339_error err)))
| "max-age" -> (
match int_of_string_opt attr_value with
| Some seconds ->
···
let now = Eio.Time.now clock in
(* Store the max-age as a Ptime.Span *)
attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
-
(* Also compute and store expires *)
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
-
attrs.expires <- expires;
Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
| None ->
Log.warn (fun m ->
m "Failed to parse max-age attribute '%s'" attr_value))
| "secure" -> attrs.secure <- true
| "httponly" -> attrs.http_only <- true
| "samesite" -> (
match String.lowercase_ascii attr_value with
| "strict" -> attrs.same_site <- Some `Strict
···
(** Validate cookie attributes and log warnings for invalid combinations *)
let validate_attributes attrs =
(* SameSite=None requires Secure flag *)
-
match attrs.same_site with
-
| Some `None when not attrs.secure ->
Log.warn (fun m ->
m
-
"Cookie has SameSite=None but Secure flag is not set; this \
-
violates RFC requirements");
-
false
-
| _ -> true
(** Build final cookie from name/value and accumulated attributes *)
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
···
let path = Option.value attrs.path ~default:request_path in
make ~domain ~path ~name ~value ~secure:attrs.secure
~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age
-
?same_site:attrs.same_site ~creation_time:now ~last_access:now ()
let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
header_value =
···
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
Some cookie)
and make_cookie_header cookies =
cookies
|> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
···
(* Add Expires if present *)
(match expires cookie with
-
| Some exp_time ->
(* Format as HTTP date *)
let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
···
(* Add HttpOnly flag *)
if http_only cookie then Buffer.add_string buffer "; HttpOnly";
(* Add SameSite *)
(match same_site cookie with
| Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
···
(** {1 Pretty Printing} *)
-
and pp_same_site ppf = function
-
| `Strict -> Format.pp_print_string ppf "Strict"
-
| `Lax -> Format.pp_print_string ppf "Lax"
-
| `None -> Format.pp_print_string ppf "None"
-
and pp ppf cookie =
Format.fprintf ppf
"@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
-
http_only=%b;@ expires=%a;@ max_age=%a;@ same_site=%a }@]"
(name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
-
(http_only cookie)
-
(Format.pp_print_option Ptime.pp)
(expires cookie)
(Format.pp_print_option Ptime.Span.pp)
(max_age cookie)
-
(Format.pp_print_option pp_same_site)
(same_site cookie)
let pp_jar ppf jar =
···
|> Option.value ~default:Ptime.epoch
in
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) ~value:""
-
~secure:(secure cookie) ~http_only:(http_only cookie) ~expires:past_expiry
-
~max_age:(Ptime.Span.of_int_s 0) ?same_site:(same_site cookie)
~creation_time:now ~last_access:now ()
let remove jar ~clock cookie =
···
let before_count =
List.length jar.original_cookies + List.length jar.delta_cookies
in
-
jar.original_cookies <-
-
List.filter (fun c -> expires c <> None) jar.original_cookies;
-
jar.delta_cookies <-
-
List.filter (fun c -> expires c <> None) jar.delta_cookies;
let removed =
before_count
- (List.length jar.original_cookies + List.length jar.delta_cookies)
···
let secure_flag = if secure cookie then "TRUE" else "FALSE" in
let expires_str =
match expires cookie with
-
| None -> "0" (* Session cookie *)
-
| Some t ->
let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
epoch
in
···
let expires =
let exp_int = try int_of_string expires with _ -> 0 in
if exp_int = 0 then None
-
else Ptime.of_float_s (float_of_int exp_int)
in
let cookie =
make ~domain:(normalize_domain domain) ~path ~name ~value
-
~secure:(secure = "TRUE") ~http_only:false ?expires
-
?max_age:None ?same_site:None ~creation_time:now
~last_access:now ()
in
add_original jar cookie;
···
module Log = (val Logs.src_log src : Logs.LOG)
+
module SameSite = struct
+
type t = [ `Strict | `Lax | `None ]
+
+
let equal = ( = )
+
+
let pp ppf = function
+
| `Strict -> Format.pp_print_string ppf "Strict"
+
| `Lax -> Format.pp_print_string ppf "Lax"
+
| `None -> Format.pp_print_string ppf "None"
+
end
+
+
module Expiration = struct
+
type t = [ `Session | `DateTime of Ptime.t ]
+
+
let equal e1 e2 =
+
match (e1, e2) with
+
| `Session, `Session -> true
+
| `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2
+
| _ -> false
+
+
let pp ppf = function
+
| `Session -> Format.pp_print_string ppf "Session"
+
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t
+
end
type t = {
domain : string;
···
value : string;
secure : bool;
http_only : bool;
+
partitioned : bool;
+
expires : Expiration.t option;
max_age : Ptime.Span.t option;
+
same_site : SameSite.t option;
creation_time : Ptime.t;
last_access : Ptime.t;
}
···
let path cookie = cookie.path
let name cookie = cookie.name
let value cookie = cookie.value
+
+
let value_trimmed cookie =
+
let v = cookie.value in
+
let len = String.length v in
+
if len < 2 then v
+
else
+
match (v.[0], v.[len - 1]) with
+
| '"', '"' -> String.sub v 1 (len - 2)
+
| _ -> v
+
let secure cookie = cookie.secure
let http_only cookie = cookie.http_only
+
let partitioned cookie = cookie.partitioned
let expires cookie = cookie.expires
let max_age cookie = cookie.max_age
let same_site cookie = cookie.same_site
···
let last_access cookie = cookie.last_access
let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
+
?expires ?max_age ?same_site ?(partitioned = false) ~creation_time
+
~last_access () =
{
domain;
path;
···
value;
secure;
http_only;
+
partitioned;
expires;
max_age;
same_site;
···
(** {1 HTTP Date Parsing} *)
let is_expired cookie clock =
match cookie.expires with
+
| None -> false (* No expiration *)
+
| Some `Session -> false (* Session cookie - not expired until browser closes *)
+
| Some (`DateTime exp_time) ->
let now =
Ptime.of_float_s (Eio.Time.now clock)
|> Option.value ~default:Ptime.epoch
···
mutable path : string option;
mutable secure : bool;
mutable http_only : bool;
+
mutable partitioned : bool;
+
mutable expires : Expiration.t option;
mutable max_age : Ptime.Span.t option;
+
mutable same_site : SameSite.t option;
}
(** Accumulated attributes from parsing Set-Cookie header *)
···
path = None;
secure = false;
http_only = false;
+
partitioned = false;
expires = None;
max_age = None;
same_site = None;
···
| "domain" -> attrs.domain <- Some (normalize_domain attr_value)
| "path" -> attrs.path <- Some attr_value
| "expires" -> (
+
(* Special case: Expires=0 means session cookie *)
+
if attr_value = "0" then attrs.expires <- Some `Session
+
else
+
match Ptime.of_rfc3339 attr_value with
+
| Ok (time, _, _) -> attrs.expires <- Some (`DateTime time)
+
| Error (`RFC3339 (_, err)) -> (
+
(* Try HTTP date format as fallback *)
+
match DateParser.parse_http_date attr_value with
+
| Some time -> attrs.expires <- Some (`DateTime time)
+
| None ->
+
Log.warn (fun m ->
+
m "Failed to parse expires attribute '%s': %a" attr_value
+
Ptime.pp_rfc3339_error err)))
| "max-age" -> (
match int_of_string_opt attr_value with
| Some seconds ->
···
let now = Eio.Time.now clock in
(* Store the max-age as a Ptime.Span *)
attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
+
(* Also compute and store expires as DateTime *)
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
+
(match expires with
+
| Some time -> attrs.expires <- Some (`DateTime time)
+
| None -> ());
Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
| None ->
Log.warn (fun m ->
m "Failed to parse max-age attribute '%s'" attr_value))
| "secure" -> attrs.secure <- true
| "httponly" -> attrs.http_only <- true
+
| "partitioned" -> attrs.partitioned <- true
| "samesite" -> (
match String.lowercase_ascii attr_value with
| "strict" -> attrs.same_site <- Some `Strict
···
(** Validate cookie attributes and log warnings for invalid combinations *)
let validate_attributes attrs =
(* SameSite=None requires Secure flag *)
+
let samesite_valid =
+
match attrs.same_site with
+
| Some `None when not attrs.secure ->
+
Log.warn (fun m ->
+
m
+
"Cookie has SameSite=None but Secure flag is not set; this \
+
violates RFC requirements");
+
false
+
| _ -> true
+
in
+
(* Partitioned requires Secure flag *)
+
let partitioned_valid =
+
if attrs.partitioned && not attrs.secure then (
Log.warn (fun m ->
m
+
"Cookie has Partitioned attribute but Secure flag is not set; \
+
this violates CHIPS requirements");
+
false)
+
else true
+
in
+
samesite_valid && partitioned_valid
(** Build final cookie from name/value and accumulated attributes *)
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
···
let path = Option.value attrs.path ~default:request_path in
make ~domain ~path ~name ~value ~secure:attrs.secure
~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age
+
?same_site:attrs.same_site ~partitioned:attrs.partitioned
+
~creation_time:now ~last_access:now ()
let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
header_value =
···
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
Some cookie)
+
and of_cookie_header ~clock ~domain ~path header_value =
+
Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
+
+
(* Split on semicolons *)
+
let parts = String.split_on_char ';' header_value |> List.map String.trim in
+
+
(* Filter out empty parts *)
+
let parts = List.filter (fun s -> String.length s > 0) parts in
+
+
(* Parse each name=value pair *)
+
List.map
+
(fun name_value ->
+
match String.index_opt name_value '=' with
+
| None ->
+
Error (Printf.sprintf "Cookie missing '=' separator: %s" name_value)
+
| Some eq_pos ->
+
let cookie_name = String.sub name_value 0 eq_pos |> String.trim in
+
if String.length cookie_name = 0 then
+
Error "Cookie has empty name"
+
else
+
let cookie_value =
+
String.sub name_value (eq_pos + 1)
+
(String.length name_value - eq_pos - 1)
+
|> String.trim
+
in
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch
+
in
+
(* Create cookie with defaults from Cookie header context *)
+
let cookie =
+
make ~domain ~path ~name:cookie_name ~value:cookie_value
+
~secure:false ~http_only:false ~partitioned:false ~creation_time:now
+
~last_access:now ()
+
in
+
Ok cookie)
+
parts
+
and make_cookie_header cookies =
cookies
|> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
···
(* Add Expires if present *)
(match expires cookie with
+
| Some `Session ->
+
(* Session cookies can be indicated with Expires=0 or a past date *)
+
Buffer.add_string buffer "; Expires=0"
+
| Some (`DateTime exp_time) ->
(* Format as HTTP date *)
let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
···
(* Add HttpOnly flag *)
if http_only cookie then Buffer.add_string buffer "; HttpOnly";
+
(* Add Partitioned flag *)
+
if partitioned cookie then Buffer.add_string buffer "; Partitioned";
+
(* Add SameSite *)
(match same_site cookie with
| Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
···
(** {1 Pretty Printing} *)
and pp ppf cookie =
Format.fprintf ppf
"@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
+
http_only=%b;@ partitioned=%b;@ expires=%a;@ max_age=%a;@ same_site=%a }@]"
(name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
+
(http_only cookie) (partitioned cookie)
+
(Format.pp_print_option Expiration.pp)
(expires cookie)
(Format.pp_print_option Ptime.Span.pp)
(max_age cookie)
+
(Format.pp_print_option SameSite.pp)
(same_site cookie)
let pp_jar ppf jar =
···
|> Option.value ~default:Ptime.epoch
in
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) ~value:""
+
~secure:(secure cookie) ~http_only:(http_only cookie)
+
~expires:(`DateTime past_expiry) ~max_age:(Ptime.Span.of_int_s 0)
+
?same_site:(same_site cookie) ~partitioned:(partitioned cookie)
~creation_time:now ~last_access:now ()
let remove jar ~clock cookie =
···
let before_count =
List.length jar.original_cookies + List.length jar.delta_cookies
in
+
(* Keep only cookies that are NOT session cookies *)
+
let is_not_session c =
+
match expires c with
+
| Some `Session -> false (* This is a session cookie, remove it *)
+
| None | Some (`DateTime _) -> true (* Keep these *)
+
in
+
jar.original_cookies <- List.filter is_not_session jar.original_cookies;
+
jar.delta_cookies <- List.filter is_not_session jar.delta_cookies;
let removed =
before_count
- (List.length jar.original_cookies + List.length jar.delta_cookies)
···
let secure_flag = if secure cookie then "TRUE" else "FALSE" in
let expires_str =
match expires cookie with
+
| None -> "0" (* No expiration *)
+
| Some `Session -> "0" (* Session cookie *)
+
| Some (`DateTime t) ->
let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
epoch
in
···
let expires =
let exp_int = try int_of_string expires with _ -> 0 in
if exp_int = 0 then None
+
else
+
match Ptime.of_float_s (float_of_int exp_int) with
+
| Some t -> Some (`DateTime t)
+
| None -> None
in
let cookie =
make ~domain:(normalize_domain domain) ~path ~name ~value
+
~secure:(secure = "TRUE") ~http_only:false ?expires ?max_age:None
+
?same_site:None ~partitioned:false ~creation_time:now
~last_access:now ()
in
add_original jar cookie;
+100 -16
lib/cookeio.mli
···
- Path matching allows subset URL specification for fine-grained control
- More specific path mappings are sent first in Cookie headers *)
-
type same_site = [ `Strict | `Lax | `None ]
-
(** Cookie same-site policy for controlling cross-site request behavior.
-
- [`Strict]: Cookie only sent for same-site requests, providing maximum
-
protection
-
- [`Lax]: Cookie sent for same-site requests and top-level navigation
-
(default for modern browsers)
-
- [`None]: Cookie sent for all cross-site requests (requires [secure] flag)
-
*)
type t
(** HTTP Cookie representation with all standard attributes.
···
val value : t -> string
(** Get the value of a cookie *)
val secure : t -> bool
(** Check if cookie is secure only *)
val http_only : t -> bool
(** Check if cookie is HTTP only *)
-
val expires : t -> Ptime.t option
-
(** Get the expiry time of a cookie *)
val max_age : t -> Ptime.Span.t option
-
(** Get the max-age of a cookie *)
-
val same_site : t -> same_site option
(** Get the same-site policy of a cookie *)
val creation_time : t -> Ptime.t
···
value:string ->
?secure:bool ->
?http_only:bool ->
-
?expires:Ptime.t ->
?max_age:Ptime.Span.t ->
-
?same_site:same_site ->
creation_time:Ptime.t ->
last_access:Ptime.t ->
unit ->
t
-
(** Create a new cookie with the given attributes *)
(** {1 Cookie Jar Creation and Loading} *)
···
Parses a Set-Cookie header value following RFC specifications:
- Basic format: [NAME=VALUE; attribute1; attribute2=value2]
- Supports all standard attributes: [expires], [max-age], [domain], [path],
-
[secure], [httponly], [samesite]
- Returns [None] if parsing fails or cookie validation fails
- The [domain] and [path] parameters provide the request context for default
values
···
Cookie validation rules:
- [SameSite=None] requires the [Secure] flag to be set
Example:
[parse_set_cookie ~clock ~domain:"example.com" ~path:"/" "session=abc123;
Secure; HttpOnly"] *)
val make_cookie_header : t list -> string
(** Create cookie header value from cookies.
···
- Path matching allows subset URL specification for fine-grained control
- More specific path mappings are sent first in Cookie headers *)
+
module SameSite : sig
+
type t = [ `Strict | `Lax | `None ]
+
(** Cookie same-site policy for controlling cross-site request behavior.
+
+
- [`Strict]: Cookie only sent for same-site requests, providing maximum
+
protection
+
- [`Lax]: Cookie sent for same-site requests and top-level navigation
+
(default for modern browsers)
+
- [`None]: Cookie sent for all cross-site requests (requires [secure] flag) *)
+
+
val equal : t -> t -> bool
+
(** Equality function for same-site values *)
+
+
val pp : Format.formatter -> t -> unit
+
(** Pretty printer for same-site values *)
+
end
+
module Expiration : sig
+
type t = [ `Session | `DateTime of Ptime.t ]
+
(** Cookie expiration strategy.
+
+
- [`Session]: Session cookie that expires when browser session ends
+
- [`DateTime time]: Persistent cookie that expires at specific time *)
+
+
val equal : t -> t -> bool
+
(** Equality function for expiration values *)
+
+
val pp : Format.formatter -> t -> unit
+
(** Pretty printer for expiration values *)
+
end
type t
(** HTTP Cookie representation with all standard attributes.
···
val value : t -> string
(** Get the value of a cookie *)
+
val value_trimmed : t -> string
+
(** Get cookie value with surrounding double-quotes removed if they form a
+
matching pair.
+
+
Only removes quotes when both opening and closing quotes are present. The
+
raw value is always preserved in {!value}. This is useful for handling
+
quoted cookie values per RFC 6265.
+
+
Examples:
+
- ["value"] → ["value"]
+
- ["\"value\""] → ["value"]
+
- ["\"value"] → ["\"value"] (no matching pair)
+
- ["\"val\"\""] → ["val\""] (removes outer pair only) *)
+
val secure : t -> bool
(** Check if cookie is secure only *)
val http_only : t -> bool
(** Check if cookie is HTTP only *)
+
val partitioned : t -> bool
+
(** Check if cookie has the Partitioned attribute.
+
+
Partitioned cookies are part of CHIPS (Cookies Having Independent
+
Partitioned State) and are stored separately per top-level site, enabling
+
privacy-preserving third-party cookie functionality. Partitioned cookies
+
must always be Secure. *)
+
+
val expires : t -> Expiration.t option
+
(** Get the expiration attribute if set.
+
+
- [None]: No expiration specified (browser decides lifetime)
+
- [Some `Session]: Session cookie (expires when browser session ends)
+
- [Some (`DateTime t)]: Expires at specific time [t]
+
+
Both [max_age] and [expires] can be present simultaneously. This library
+
stores both independently. *)
val max_age : t -> Ptime.Span.t option
+
(** Get the max-age attribute if set.
+
Both [max_age] and [expires] can be present simultaneously. When both are
+
present in a Set-Cookie header, browsers prioritize [max_age] per RFC 6265.
+
This library stores both independently and serializes both when present. *)
+
+
val same_site : t -> SameSite.t option
(** Get the same-site policy of a cookie *)
val creation_time : t -> Ptime.t
···
value:string ->
?secure:bool ->
?http_only:bool ->
+
?expires:Expiration.t ->
?max_age:Ptime.Span.t ->
+
?same_site:SameSite.t ->
+
?partitioned:bool ->
creation_time:Ptime.t ->
last_access:Ptime.t ->
unit ->
t
+
(** Create a new cookie with the given attributes.
+
+
Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid
+
combinations will result in validation errors. *)
(** {1 Cookie Jar Creation and Loading} *)
···
Parses a Set-Cookie header value following RFC specifications:
- Basic format: [NAME=VALUE; attribute1; attribute2=value2]
- Supports all standard attributes: [expires], [max-age], [domain], [path],
+
[secure], [httponly], [samesite], [partitioned]
- Returns [None] if parsing fails or cookie validation fails
- The [domain] and [path] parameters provide the request context for default
values
···
Cookie validation rules:
- [SameSite=None] requires the [Secure] flag to be set
+
- [Partitioned] requires the [Secure] flag to be set
Example:
[parse_set_cookie ~clock ~domain:"example.com" ~path:"/" "session=abc123;
Secure; HttpOnly"] *)
+
+
val of_cookie_header :
+
clock:_ Eio.Time.clock ->
+
domain:string ->
+
path:string ->
+
string ->
+
(t, string) result list
+
(** Parse Cookie header containing semicolon-separated name=value pairs.
+
+
Cookie headers (client→server) contain only name=value pairs without
+
attributes: ["name1=value1; name2=value2; name3=value3"]
+
+
Creates cookies with:
+
- Provided [domain] and [path] from request context
+
- All security flags set to [false] (defaults)
+
- All optional attributes set to [None]
+
- [creation_time] and [last_access] set to current time from [clock]
+
+
Returns a list of parse results, one per cookie. Parse errors for individual
+
cookies are returned as [Error msg] without failing the entire parse. Empty
+
values and excess whitespace are ignored.
+
+
Example:
+
[of_cookie_header ~clock ~domain:"example.com" ~path:"/"
+
"session=abc; theme=dark"] *)
val make_cookie_header : t list -> string
(** Create cookie header value from cookies.
+1 -1
test/dune
···
(test
(name test_cookeio)
-
(libraries cookeio alcotest eio eio.unix eio_main eio.mock ptime)
(deps cookies.txt))
···
(test
(name test_cookeio)
+
(libraries cookeio alcotest eio eio.unix eio_main eio.mock ptime str)
(deps cookies.txt))
+528 -78
test/test_cookeio.ml
···
open Cookeio
let cookie_testable : Cookeio.t Alcotest.testable =
Alcotest.testable
(fun ppf c ->
Format.fprintf ppf
"{ 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)
(Cookeio.expires c)
(Format.pp_print_option Ptime.Span.pp)
(Cookeio.max_age c)
-
(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.same_site c))
(fun c1 c2 ->
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))
···
Alcotest.(check string) "cookie-1 value" "v$1" (Cookeio.value cookie1);
Alcotest.(check bool) "cookie-1 secure" false (Cookeio.secure cookie1);
Alcotest.(check bool) "cookie-1 http_only" false (Cookeio.http_only cookie1);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
"cookie-1 expires" None (Cookeio.expires cookie1);
Alcotest.(
check
···
Alcotest.(check string) "cookie-2 value" "v$2" (Cookeio.value cookie2);
Alcotest.(check bool) "cookie-2 secure" false (Cookeio.secure cookie2);
Alcotest.(check bool) "cookie-2 http_only" false (Cookeio.http_only cookie2);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
"cookie-2 expires" None (Cookeio.expires cookie2);
(* Test cookie-3: non-session cookie with expiry *)
···
Alcotest.(check string) "cookie-3 value" "v$3" (Cookeio.value cookie3);
Alcotest.(check bool) "cookie-3 secure" false (Cookeio.secure cookie3);
Alcotest.(check bool) "cookie-3 http_only" false (Cookeio.http_only cookie3);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"cookie-3 expires" expected_expiry (Cookeio.expires cookie3);
(* Test cookie-4: another non-session cookie *)
let cookie4 = find_cookie "cookie-4" in
···
Alcotest.(check string) "cookie-4 value" "v$4" (Cookeio.value cookie4);
Alcotest.(check bool) "cookie-4 secure" false (Cookeio.secure cookie4);
Alcotest.(check bool) "cookie-4 http_only" false (Cookeio.http_only cookie4);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"cookie-4 expires" expected_expiry (Cookeio.expires cookie4);
(* Test cookie-5: secure cookie *)
let cookie5 = find_cookie "cookie-5" in
···
Alcotest.(check string) "cookie-5 value" "v$5" (Cookeio.value cookie5);
Alcotest.(check bool) "cookie-5 secure" true (Cookeio.secure cookie5);
Alcotest.(check bool) "cookie-5 http_only" false (Cookeio.http_only cookie5);
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"cookie-5 expires" expected_expiry (Cookeio.expires cookie5)
let test_load_from_file env =
(* This test loads from the actual test/cookies.txt file using the load function *)
···
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);
let cookie5 = find_cookie "cookie-5" in
Alcotest.(check string) "file cookie-5 value" "v$5" (Cookeio.value cookie5);
Alcotest.(check bool) "file cookie-5 secure" true (Cookeio.secure cookie5);
let expected_expiry = Ptime.of_float_s 1257894000.0 in
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"file cookie-5 expires" expected_expiry (Cookeio.expires cookie5);
(* 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)
let test_cookie_matching env =
···
let jar = create () in
let test_cookie =
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 ()
in
add_cookie jar test_cookie;
···
Alcotest.(check string) "round trip path" "/test/" (Cookeio.path cookie2);
Alcotest.(check bool) "round trip secure" true (Cookeio.secure cookie2);
(* Note: http_only and same_site are lost in Mozilla format *)
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"round trip expires"
-
(Ptime.of_float_s 1257894000.0)
-
(Cookeio.expires cookie2)
let test_cookie_expiry_with_mock_clock () =
Eio_mock.Backend.run @@ fun () ->
···
let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in
let cookie1 =
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)
()
···
let expires_later = Ptime.of_float_s 2000.0 |> Option.get in
let cookie2 =
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)
()
···
(* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *)
let expected_expiry = Ptime.of_float_s 8600.0 in
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"expires set from max-age" expected_expiry (Cookeio.expires cookie);
(* Verify creation time matches clock time *)
let expected_creation = Ptime.of_float_s 5000.0 in
···
let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
match expected_expiry with
| Ok (time, _, _) ->
-
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
-
"expires matches parsed value" (Some time) (Cookeio.expires cookie)
| Error _ -> Alcotest.fail "Failed to parse expected expiry time"
let test_samesite_none_validation () =
···
(* Verify expires is also computed correctly *)
let expected_expiry = Ptime.of_float_s 8600.0 in
-
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 () ->
···
(* 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 =
try
···
let expires_time = Ptime.of_float_s 8600.0 |> Option.get in
let cookie =
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)
···
(* 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 () ->
···
(* 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 () ->
···
(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 () ->
···
(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 () ->
···
in
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
···
in
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
···
in
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 () ->
···
in
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
···
in
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
···
in
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 expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
match expected with
| Ok (time, _, _) ->
-
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 () =
···
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 () =
···
(* 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;
-
Alcotest.(check int)
-
(description ^ " month correct (October=10)")
-
10 month)
test_cases
let test_case_insensitive_gmt_parsing () =
···
(* Verify the date was parsed correctly regardless of GMT case *)
let expires = Option.get (Cookeio.expires cookie) in
-
let year, month, day = Ptime.to_date expires in
-
Alcotest.(check int) (description ^ " year correct") 2015 year;
-
Alcotest.(check int)
-
(description ^ " month correct (October=10)")
-
10 month;
-
Alcotest.(check int) (description ^ " day correct") 21 day)
test_cases
(** {1 Delta Tracking Tests} *)
···
(* Check expires is in the past *)
let now = Ptime.of_float_s 1000.0 |> Option.get in
match Cookeio.expires removal with
-
| Some exp ->
Alcotest.(check bool)
"expires is in the past" true
(Ptime.compare exp now < 0)
-
| None -> Alcotest.fail "removal cookie should have expires"
let () =
Eio_main.run @@ fun env ->
···
test_case_insensitive_month_parsing;
test_case "Case-insensitive GMT parsing" `Quick
test_case_insensitive_gmt_parsing;
] );
]
···
open Cookeio
+
(* Testable helpers for Priority 2 types *)
+
let expiration_testable : Cookeio.Expiration.t Alcotest.testable =
+
Alcotest.testable Cookeio.Expiration.pp Cookeio.Expiration.equal
+
+
let span_testable : Ptime.Span.t Alcotest.testable =
+
Alcotest.testable Ptime.Span.pp Ptime.Span.equal
+
+
let same_site_testable : Cookeio.SameSite.t Alcotest.testable =
+
Alcotest.testable Cookeio.SameSite.pp Cookeio.SameSite.equal
+
let cookie_testable : Cookeio.t Alcotest.testable =
Alcotest.testable
(fun ppf c ->
Format.fprintf ppf
"{ name=%S; value=%S; domain=%S; path=%S; secure=%b; http_only=%b; \
+
partitioned=%b; expires=%a; max_age=%a; same_site=%a }"
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c)
+
(Cookeio.secure c) (Cookeio.http_only c) (Cookeio.partitioned c)
+
(Format.pp_print_option
+
(fun ppf e ->
+
match e with
+
| `Session -> Format.pp_print_string ppf "Session"
+
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t))
(Cookeio.expires c)
(Format.pp_print_option Ptime.Span.pp)
(Cookeio.max_age c)
+
(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.same_site c))
(fun c1 c2 ->
+
let expires_equal e1 e2 =
+
match (e1, e2) with
+
| None, None -> true
+
| Some `Session, Some `Session -> true
+
| Some (`DateTime t1), Some (`DateTime t2) -> Ptime.equal t1 t2
+
| _ -> false
+
in
Cookeio.name c1 = Cookeio.name c2
&& Cookeio.value c1 = Cookeio.value c2
&& Cookeio.domain c1 = Cookeio.domain c2
&& Cookeio.path c1 = Cookeio.path c2
&& Cookeio.secure c1 = Cookeio.secure c2
&& Cookeio.http_only c1 = Cookeio.http_only c2
+
&& Cookeio.partitioned c1 = Cookeio.partitioned c2
+
&& expires_equal (Cookeio.expires c1) (Cookeio.expires c2)
&& Option.equal Ptime.Span.equal (Cookeio.max_age c1) (Cookeio.max_age c2)
&& Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
···
Alcotest.(check string) "cookie-1 value" "v$1" (Cookeio.value cookie1);
Alcotest.(check bool) "cookie-1 secure" false (Cookeio.secure cookie1);
Alcotest.(check bool) "cookie-1 http_only" false (Cookeio.http_only cookie1);
+
Alcotest.(check (option expiration_testable))
"cookie-1 expires" None (Cookeio.expires cookie1);
Alcotest.(
check
···
Alcotest.(check string) "cookie-2 value" "v$2" (Cookeio.value cookie2);
Alcotest.(check bool) "cookie-2 secure" false (Cookeio.secure cookie2);
Alcotest.(check bool) "cookie-2 http_only" false (Cookeio.http_only cookie2);
+
Alcotest.(check (option expiration_testable))
"cookie-2 expires" None (Cookeio.expires cookie2);
(* Test cookie-3: non-session cookie with expiry *)
···
Alcotest.(check string) "cookie-3 value" "v$3" (Cookeio.value cookie3);
Alcotest.(check bool) "cookie-3 secure" false (Cookeio.secure cookie3);
Alcotest.(check bool) "cookie-3 http_only" false (Cookeio.http_only cookie3);
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"cookie-3 expires" (Some (`DateTime t)) (Cookeio.expires cookie3)
+
| None -> Alcotest.fail "Expected expiry time for cookie-3"
+
end;
(* Test cookie-4: another non-session cookie *)
let cookie4 = find_cookie "cookie-4" in
···
Alcotest.(check string) "cookie-4 value" "v$4" (Cookeio.value cookie4);
Alcotest.(check bool) "cookie-4 secure" false (Cookeio.secure cookie4);
Alcotest.(check bool) "cookie-4 http_only" false (Cookeio.http_only cookie4);
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"cookie-4 expires" (Some (`DateTime t)) (Cookeio.expires cookie4)
+
| None -> Alcotest.fail "Expected expiry time for cookie-4"
+
end;
(* Test cookie-5: secure cookie *)
let cookie5 = find_cookie "cookie-5" in
···
Alcotest.(check string) "cookie-5 value" "v$5" (Cookeio.value cookie5);
Alcotest.(check bool) "cookie-5 secure" true (Cookeio.secure cookie5);
Alcotest.(check bool) "cookie-5 http_only" false (Cookeio.http_only cookie5);
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"cookie-5 expires" (Some (`DateTime t)) (Cookeio.expires cookie5)
+
| None -> Alcotest.fail "Expected expiry time for cookie-5"
+
end
let test_load_from_file env =
(* This test loads from the actual test/cookies.txt file using the load function *)
···
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 expiration_testable))
"file cookie-1 expires" None (Cookeio.expires cookie1);
let cookie5 = find_cookie "cookie-5" in
Alcotest.(check string) "file cookie-5 value" "v$5" (Cookeio.value cookie5);
Alcotest.(check bool) "file cookie-5 secure" true (Cookeio.secure cookie5);
let expected_expiry = Ptime.of_float_s 1257894000.0 in
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"file cookie-5 expires" (Some (`DateTime t)) (Cookeio.expires cookie5)
+
| None -> Alcotest.fail "Expected expiry time for cookie-5"
+
end;
(* 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 expiration_testable))
"file cookie-2 expires" None (Cookeio.expires cookie2)
let test_cookie_matching env =
···
let jar = create () in
let test_cookie =
+
let expires =
+
match Ptime.of_float_s 1257894000.0 with
+
| Some t -> Some (`DateTime t)
+
| None -> None
+
in
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test"
+
~value:"value" ~secure:true ~http_only:false ?expires ~same_site:`Strict
+
?max_age:None ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
in
add_cookie jar test_cookie;
···
Alcotest.(check string) "round trip path" "/test/" (Cookeio.path cookie2);
Alcotest.(check bool) "round trip secure" true (Cookeio.secure cookie2);
(* Note: http_only and same_site are lost in Mozilla format *)
+
begin match Ptime.of_float_s 1257894000.0 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"round trip expires" (Some (`DateTime t)) (Cookeio.expires cookie2)
+
| None -> Alcotest.fail "Expected expiry time"
+
end
let test_cookie_expiry_with_mock_clock () =
Eio_mock.Backend.run @@ fun () ->
···
let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in
let cookie1 =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon"
+
~value:"value1" ~secure:false ~http_only:false
+
~expires:(`DateTime expires_soon) ?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
()
···
let expires_later = Ptime.of_float_s 2000.0 |> Option.get in
let cookie2 =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later"
+
~value:"value2" ~secure:false ~http_only:false
+
~expires:(`DateTime expires_later) ?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
()
···
(* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *)
let expected_expiry = Ptime.of_float_s 8600.0 in
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"expires set from max-age" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time"
+
end;
(* Verify creation time matches clock time *)
let expected_creation = Ptime.of_float_s 5000.0 in
···
let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
match expected_expiry with
| Ok (time, _, _) ->
+
Alcotest.(check (option expiration_testable))
+
"expires matches parsed value" (Some (`DateTime time))
+
(Cookeio.expires cookie)
| Error _ -> Alcotest.fail "Failed to parse expected expiry time"
let test_samesite_none_validation () =
···
(* Verify expires is also computed correctly *)
let expected_expiry = Ptime.of_float_s 8600.0 in
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"expires computed from max-age" (Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time"
+
end
let test_max_age_negative_becomes_zero () =
Eio_mock.Backend.run @@ fun () ->
···
(* Verify expires is computed with 0 seconds *)
let expected_expiry = Ptime.of_float_s 5000.0 in
+
begin match expected_expiry with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"expires computed with 0 seconds" (Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time"
+
end
let string_contains_substring s sub =
try
···
let expires_time = Ptime.of_float_s 8600.0 |> Option.get in
let cookie =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"abc123"
+
~secure:true ~http_only:true ?expires:(Some (`DateTime expires_time))
?max_age:(Some max_age_span) ?same_site:(Some `Strict)
~creation_time:(Ptime.of_float_s 5000.0 |> Option.get)
~last_access:(Ptime.of_float_s 5000.0 |> Option.get)
···
(* Verify the parsed time matches expected value *)
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"FMT1 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for FMT1"
+
end
let test_http_date_fmt2 () =
Eio_mock.Backend.run @@ fun () ->
···
(* Year 15 should be normalized to 2015 *)
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"FMT2 expiry correct with year normalization" (Some (`DateTime t))
+
(Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for FMT2"
+
end
let test_http_date_fmt3 () =
Eio_mock.Backend.run @@ fun () ->
···
(Option.is_some (Cookeio.expires cookie));
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"FMT3 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for FMT3"
+
end
let test_http_date_fmt4 () =
Eio_mock.Backend.run @@ fun () ->
···
(Option.is_some (Cookeio.expires cookie));
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
begin match expected with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"FMT4 expiry correct" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for FMT4"
+
end
let test_abbreviated_year_69_to_99 () =
Eio_mock.Backend.run @@ fun () ->
···
in
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
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 95 becomes 1995" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for year 95"
+
end;
(* Year 69 should become 1969 *)
let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in
···
in
let cookie2 = Option.get cookie_opt2 in
let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
+
begin match expected2 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 69 becomes 1969" (Some (`DateTime t)) (Cookeio.expires cookie2)
+
| None -> Alcotest.fail "Expected expiry time for year 69"
+
end;
(* Year 99 should become 1999 *)
let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in
···
in
let cookie3 = Option.get cookie_opt3 in
let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
+
begin match expected3 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 99 becomes 1999" (Some (`DateTime t)) (Cookeio.expires cookie3)
+
| None -> Alcotest.fail "Expected expiry time for year 99"
+
end
let test_abbreviated_year_0_to_68 () =
Eio_mock.Backend.run @@ fun () ->
···
in
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
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 25 becomes 2025" (Some (`DateTime t)) (Cookeio.expires cookie)
+
| None -> Alcotest.fail "Expected expiry time for year 25"
+
end;
(* Year 0 should become 2000 *)
let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in
···
in
let cookie2 = Option.get cookie_opt2 in
let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
+
begin match expected2 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 0 becomes 2000" (Some (`DateTime t)) (Cookeio.expires cookie2)
+
| None -> Alcotest.fail "Expected expiry time for year 0"
+
end;
(* Year 68 should become 2068 *)
let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in
···
in
let cookie3 = Option.get cookie_opt3 in
let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
+
begin match expected3 with
+
| Some t ->
+
Alcotest.(check (option expiration_testable))
+
"year 68 becomes 2068" (Some (`DateTime t)) (Cookeio.expires cookie3)
+
| None -> Alcotest.fail "Expected expiry time for year 68"
+
end
let test_rfc3339_still_works () =
Eio_mock.Backend.run @@ fun () ->
···
let expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
match expected with
| Ok (time, _, _) ->
+
Alcotest.(check (option expiration_testable))
+
"RFC 3339 expiry correct" (Some (`DateTime time)) (Cookeio.expires cookie)
| Error _ -> Alcotest.fail "Failed to parse expected RFC 3339 time"
let test_invalid_date_format_logs_warning () =
···
Alcotest.(check string) "cookie name correct" "session" (Cookeio.name cookie);
Alcotest.(check string) "cookie value correct" "abc" (Cookeio.value cookie);
(* expires should be None since date was invalid *)
+
Alcotest.(check (option expiration_testable))
"expires is None for invalid date" None (Cookeio.expires cookie)
let test_case_insensitive_month_parsing () =
···
(* Verify the date was parsed correctly regardless of case *)
let expires = Option.get (Cookeio.expires cookie) in
+
match expires with
+
| `DateTime ptime ->
+
let year, month, _ = Ptime.to_date ptime in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
Alcotest.(check int)
+
(description ^ " month correct (October=10)")
+
10 month
+
| `Session -> Alcotest.fail (description ^ " should not be session cookie"))
test_cases
let test_case_insensitive_gmt_parsing () =
···
(* Verify the date was parsed correctly regardless of GMT case *)
let expires = Option.get (Cookeio.expires cookie) in
+
match expires with
+
| `DateTime ptime ->
+
let year, month, day = Ptime.to_date ptime in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
Alcotest.(check int)
+
(description ^ " month correct (October=10)")
+
10 month;
+
Alcotest.(check int) (description ^ " day correct") 21 day
+
| `Session -> Alcotest.fail (description ^ " should not be session cookie"))
test_cases
(** {1 Delta Tracking Tests} *)
···
(* Check expires is in the past *)
let now = Ptime.of_float_s 1000.0 |> Option.get in
match Cookeio.expires removal with
+
| Some (`DateTime exp) ->
Alcotest.(check bool)
"expires is in the past" true
(Ptime.compare exp now < 0)
+
| _ -> Alcotest.fail "removal cookie should have DateTime expires"
+
+
(* ============================================================================ *)
+
(* Priority 2 Tests *)
+
(* ============================================================================ *)
+
+
(* Priority 2.1: Partitioned Cookies *)
+
+
let test_partitioned_parsing env =
+
let clock = Eio.Stdenv.clock env in
+
+
match parse_set_cookie ~clock ~domain:"widget.com" ~path:"/"
+
"id=123; Partitioned; Secure" with
+
| Some c ->
+
Alcotest.(check bool) "partitioned flag" true (partitioned c);
+
Alcotest.(check bool) "secure flag" true (secure c)
+
| None -> Alcotest.fail "Should parse valid Partitioned cookie"
+
+
let test_partitioned_serialization env =
+
let clock = Eio.Stdenv.clock env in
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
+
+
let cookie = make ~domain:"widget.com" ~path:"/" ~name:"id" ~value:"123"
+
~secure:true ~partitioned:true
+
~creation_time:now ~last_access:now () in
+
+
let header = make_set_cookie_header cookie in
+
let contains_substring s sub =
+
try
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
true
+
with Not_found -> false
+
in
+
let has_partitioned = contains_substring header "Partitioned" in
+
let has_secure = contains_substring header "Secure" in
+
Alcotest.(check bool) "contains Partitioned" true has_partitioned;
+
Alcotest.(check bool) "contains Secure" true has_secure
+
+
let test_partitioned_requires_secure env =
+
let clock = Eio.Stdenv.clock env in
+
+
(* Partitioned without Secure should be rejected *)
+
match parse_set_cookie ~clock ~domain:"widget.com" ~path:"/"
+
"id=123; Partitioned" with
+
| None -> () (* Expected *)
+
| Some _ -> Alcotest.fail "Should reject Partitioned without Secure"
+
+
(* Priority 2.2: Expiration Variants *)
+
+
let test_expiration_variants env =
+
let clock = Eio.Stdenv.clock env in
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
+
let make_base ~name ?expires () =
+
make ~domain:"ex.com" ~path:"/" ~name ~value:"v"
+
?expires ~creation_time:now ~last_access:now ()
+
in
+
+
(* No expiration *)
+
let c1 = make_base ~name:"no_expiry" () in
+
Alcotest.(check (option expiration_testable)) "no expiration"
+
None (expires c1);
+
+
(* Session cookie *)
+
let c2 = make_base ~name:"session" ~expires:`Session () in
+
Alcotest.(check (option expiration_testable)) "session cookie"
+
(Some `Session) (expires c2);
+
+
(* Explicit expiration *)
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
+
let c3 = make_base ~name:"persistent" ~expires:(`DateTime future) () in
+
match expires c3 with
+
| Some (`DateTime t) when Ptime.equal t future -> ()
+
| _ -> Alcotest.fail "Expected DateTime expiration"
+
+
let test_parse_session_expiration env =
+
let clock = Eio.Stdenv.clock env in
+
+
(* Expires=0 should parse as Session *)
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
+
"id=123; Expires=0" with
+
| Some c ->
+
Alcotest.(check (option expiration_testable)) "expires=0 is session"
+
(Some `Session) (expires c)
+
| None -> Alcotest.fail "Should parse Expires=0"
+
+
let test_serialize_expiration_variants env =
+
let clock = Eio.Stdenv.clock env in
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
+
let contains_substring s sub =
+
try
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
true
+
with Not_found -> false
+
in
+
+
(* Session cookie serialization *)
+
let c1 = make ~domain:"ex.com" ~path:"/" ~name:"s" ~value:"v"
+
~expires:`Session ~creation_time:now ~last_access:now () in
+
let h1 = make_set_cookie_header c1 in
+
let has_expires = contains_substring h1 "Expires=" in
+
Alcotest.(check bool) "session has Expires" true has_expires;
+
+
(* DateTime serialization *)
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
+
let c2 = make ~domain:"ex.com" ~path:"/" ~name:"p" ~value:"v"
+
~expires:(`DateTime future) ~creation_time:now ~last_access:now () in
+
let h2 = make_set_cookie_header c2 in
+
let has_expires2 = contains_substring h2 "Expires=" in
+
Alcotest.(check bool) "datetime has Expires" true has_expires2
+
+
(* Priority 2.3: Value Trimming *)
+
+
let test_quoted_cookie_values env =
+
let clock = Eio.Stdenv.clock env in
+
let test_cases = [
+
("name=value", "value", "value");
+
("name=\"value\"", "\"value\"", "value");
+
("name=\"partial", "\"partial", "\"partial");
+
("name=\"val\"\"", "\"val\"\"", "val\"");
+
("name=val\"", "val\"", "val\"");
+
("name=\"\"", "\"\"", "");
+
] in
+
+
List.iter (fun (input, expected_raw, expected_trimmed) ->
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/" input with
+
| Some c ->
+
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)
+
) test_cases
+
+
let test_trimmed_value_not_used_for_equality env =
+
let clock = Eio.Stdenv.clock env in
+
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
+
"name=\"value\"" with
+
| Some c1 ->
+
begin match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
+
"name=value" with
+
| Some c2 ->
+
(* Different raw values *)
+
Alcotest.(check bool) "different raw values" false
+
(value c1 = value c2);
+
(* Same trimmed values *)
+
Alcotest.(check string) "same trimmed values"
+
(value_trimmed c1) (value_trimmed c2)
+
| None -> Alcotest.fail "Parse failed for unquoted"
+
end
+
| None -> Alcotest.fail "Parse failed for quoted"
+
+
(* Priority 2.4: Cookie Header Parsing *)
+
+
let test_cookie_header_parsing_basic env =
+
let clock = Eio.Stdenv.clock env in
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/"
+
"session=abc123; theme=dark; lang=en" in
+
+
let cookies = List.filter_map Result.to_option results in
+
Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
+
+
let find name_val = List.find (fun c -> name c = name_val) cookies in
+
Alcotest.(check string) "session value" "abc123" (value (find "session"));
+
Alcotest.(check string) "theme value" "dark" (value (find "theme"));
+
Alcotest.(check string) "lang value" "en" (value (find "lang"))
+
+
let test_cookie_header_defaults env =
+
let clock = Eio.Stdenv.clock env in
+
+
match of_cookie_header ~clock ~domain:"example.com" ~path:"/app"
+
"session=xyz" with
+
| [Ok c] ->
+
(* Domain and path from request context *)
+
Alcotest.(check string) "domain from context" "example.com" (domain c);
+
Alcotest.(check string) "path from context" "/app" (path c);
+
+
(* Security flags default to false *)
+
Alcotest.(check bool) "secure default" false (secure c);
+
Alcotest.(check bool) "http_only default" false (http_only c);
+
Alcotest.(check bool) "partitioned default" false (partitioned c);
+
+
(* Optional attributes default to None *)
+
Alcotest.(check (option expiration_testable)) "no expiration"
+
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 =
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/" input in
+
let cookies = List.filter_map Result.to_option results in
+
Alcotest.(check int) description expected_count (List.length cookies)
+
in
+
+
test "" 0 "empty string";
+
test ";;" 0 "only separators";
+
test "a=1;;b=2" 2 "double separator";
+
test " a=1 ; b=2 " 2 "excess whitespace";
+
test " " 0 "only whitespace"
+
+
let test_cookie_header_with_errors env =
+
let clock = Eio.Stdenv.clock env in
+
+
(* Mix of valid and invalid cookies *)
+
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/"
+
"valid=1;=noname;valid2=2" in
+
+
Alcotest.(check int) "total results" 3 (List.length results);
+
+
let successes = List.filter Result.is_ok results in
+
let errors = List.filter Result.is_error results in
+
+
Alcotest.(check int) "successful parses" 2 (List.length successes);
+
Alcotest.(check int) "failed parses" 1 (List.length errors);
+
+
(* Error should have descriptive message *)
+
let contains_substring s sub =
+
try
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
true
+
with Not_found -> false
+
in
+
begin match List.hd errors with
+
| Error msg ->
+
let has_name = contains_substring msg "name" in
+
let has_empty = contains_substring msg "empty" in
+
Alcotest.(check bool) "error mentions name or empty" true
+
(has_name || has_empty)
+
| Ok _ -> Alcotest.fail "Expected error"
+
end
+
+
(* Max-Age and Expires Interaction *)
+
+
let test_max_age_and_expires_both_present env =
+
let clock = Eio.Stdenv.clock env in
+
let now = Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch in
+
let future = Ptime.add_span now (Ptime.Span.of_int_s 7200) |> Option.get in
+
+
(* Create cookie with both *)
+
let cookie = make ~domain:"ex.com" ~path:"/" ~name:"dual" ~value:"val"
+
~max_age:(Ptime.Span.of_int_s 3600)
+
~expires:(`DateTime future)
+
~creation_time:now ~last_access:now () in
+
+
(* Both should be present *)
+
begin match max_age cookie with
+
| Some span ->
+
begin match Ptime.Span.to_int_s span with
+
| Some s ->
+
Alcotest.(check int64) "max_age present" 3600L (Int64.of_int s)
+
| None -> Alcotest.fail "max_age span could not be converted to int"
+
end
+
| None -> Alcotest.fail "max_age should be present"
+
end;
+
+
begin match expires cookie with
+
| Some (`DateTime t) when Ptime.equal t future -> ()
+
| _ -> Alcotest.fail "expires should be present"
+
end;
+
+
(* Both should appear in serialization *)
+
let header = make_set_cookie_header cookie in
+
let contains_substring s sub =
+
try
+
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
+
true
+
with Not_found -> false
+
in
+
let has_max_age = contains_substring header "Max-Age=3600" in
+
let has_expires = contains_substring header "Expires=" in
+
Alcotest.(check bool) "contains Max-Age" true has_max_age;
+
Alcotest.(check bool) "contains Expires" true has_expires
+
+
let test_parse_max_age_and_expires env =
+
let clock = Eio.Stdenv.clock env in
+
+
(* Parse Set-Cookie with both attributes *)
+
match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
+
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT" with
+
| Some c ->
+
(* Both should be stored *)
+
begin match max_age c with
+
| Some span ->
+
begin match Ptime.Span.to_int_s span with
+
| Some s ->
+
Alcotest.(check int64) "max_age parsed" 3600L (Int64.of_int s)
+
| None -> Alcotest.fail "max_age span could not be converted to int"
+
end
+
| None -> Alcotest.fail "max_age should be parsed"
+
end;
+
+
begin match expires c with
+
| Some (`DateTime _) -> ()
+
| _ -> Alcotest.fail "expires should be parsed"
+
end
+
| None -> Alcotest.fail "Should parse cookie with both attributes"
let () =
Eio_main.run @@ fun env ->
···
test_case_insensitive_month_parsing;
test_case "Case-insensitive GMT parsing" `Quick
test_case_insensitive_gmt_parsing;
+
] );
+
( "partitioned",
+
[
+
test_case "parse partitioned cookie" `Quick (fun () ->
+
test_partitioned_parsing env);
+
test_case "serialize partitioned cookie" `Quick (fun () ->
+
test_partitioned_serialization env);
+
test_case "partitioned requires secure" `Quick (fun () ->
+
test_partitioned_requires_secure env);
+
] );
+
( "expiration",
+
[
+
test_case "expiration variants" `Quick (fun () ->
+
test_expiration_variants env);
+
test_case "parse session expiration" `Quick (fun () ->
+
test_parse_session_expiration env);
+
test_case "serialize expiration variants" `Quick (fun () ->
+
test_serialize_expiration_variants env);
+
] );
+
( "value_trimming",
+
[
+
test_case "quoted values" `Quick (fun () ->
+
test_quoted_cookie_values env);
+
test_case "trimmed not used for equality" `Quick (fun () ->
+
test_trimmed_value_not_used_for_equality env);
+
] );
+
( "cookie_header",
+
[
+
test_case "parse basic" `Quick (fun () ->
+
test_cookie_header_parsing_basic env);
+
test_case "default values" `Quick (fun () ->
+
test_cookie_header_defaults env);
+
test_case "edge cases" `Quick (fun () ->
+
test_cookie_header_edge_cases env);
+
test_case "multiple with errors" `Quick (fun () ->
+
test_cookie_header_with_errors env);
+
] );
+
( "max_age_expires_interaction",
+
[
+
test_case "both present" `Quick (fun () ->
+
test_max_age_and_expires_both_present env);
+
test_case "parse both" `Quick (fun () ->
+
test_parse_max_age_and_expires env);
] );
]