OCaml HTTP cookie handling library with support for Eio-based storage jars
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6open Cookeio
7open Cookeio_jar
8
9(* Testable helpers for Priority 2 types *)
10let expiration_testable : Cookeio.Expiration.t Alcotest.testable =
11 Alcotest.testable Cookeio.Expiration.pp Cookeio.Expiration.equal
12
13let span_testable : Ptime.Span.t Alcotest.testable =
14 Alcotest.testable Ptime.Span.pp Ptime.Span.equal
15
16let same_site_testable : Cookeio.SameSite.t Alcotest.testable =
17 Alcotest.testable Cookeio.SameSite.pp Cookeio.SameSite.equal
18
19let cookie_testable : Cookeio.t Alcotest.testable =
20 Alcotest.testable
21 (fun ppf c ->
22 Format.fprintf ppf
23 "{ name=%S; value=%S; domain=%S; path=%S; secure=%b; http_only=%b; \
24 partitioned=%b; expires=%a; max_age=%a; same_site=%a }"
25 (Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c)
26 (Cookeio.secure c) (Cookeio.http_only c) (Cookeio.partitioned c)
27 (Format.pp_print_option (fun ppf e ->
28 match e with
29 | `Session -> Format.pp_print_string ppf "Session"
30 | `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t))
31 (Cookeio.expires c)
32 (Format.pp_print_option Ptime.Span.pp)
33 (Cookeio.max_age c)
34 (Format.pp_print_option (fun ppf -> function
35 | `Strict -> Format.pp_print_string ppf "Strict"
36 | `Lax -> Format.pp_print_string ppf "Lax"
37 | `None -> Format.pp_print_string ppf "None"))
38 (Cookeio.same_site c))
39 (fun c1 c2 ->
40 let expires_equal e1 e2 =
41 match (e1, e2) with
42 | None, None -> true
43 | Some `Session, Some `Session -> true
44 | Some (`DateTime t1), Some (`DateTime t2) -> Ptime.equal t1 t2
45 | _ -> false
46 in
47 Cookeio.name c1 = Cookeio.name c2
48 && Cookeio.value c1 = Cookeio.value c2
49 && Cookeio.domain c1 = Cookeio.domain c2
50 && Cookeio.path c1 = Cookeio.path c2
51 && Cookeio.secure c1 = Cookeio.secure c2
52 && Cookeio.http_only c1 = Cookeio.http_only c2
53 && Cookeio.partitioned c1 = Cookeio.partitioned c2
54 && expires_equal (Cookeio.expires c1) (Cookeio.expires c2)
55 && Option.equal Ptime.Span.equal (Cookeio.max_age c1) (Cookeio.max_age c2)
56 && Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
57
58let test_load_mozilla_cookies env =
59 let clock = Eio.Stdenv.clock env in
60 let content =
61 {|# Netscape HTTP Cookie File
62# http://curl.haxx.se/rfc/cookie_spec.html
63# This is a generated file! Do not edit.
64
65example.com FALSE /foo/ FALSE 0 cookie-1 v$1
66.example.com TRUE /foo/ FALSE 0 cookie-2 v$2
67example.com FALSE /foo/ FALSE 1257894000 cookie-3 v$3
68example.com FALSE /foo/ FALSE 1257894000 cookie-4 v$4
69example.com FALSE /foo/ TRUE 1257894000 cookie-5 v$5
70#HttpOnly_example.com FALSE /foo/ FALSE 1257894000 cookie-6 v$6
71#HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7
72|}
73 in
74 let jar = from_mozilla_format ~clock content in
75 let cookies = get_all_cookies jar in
76
77 (* Check total number of cookies (should skip commented lines) *)
78 Alcotest.(check int) "cookie count" 5 (List.length cookies);
79 Alcotest.(check int) "count function" 5 (count jar);
80 Alcotest.(check bool) "not empty" false (is_empty jar);
81
82 let find_cookie name = List.find (fun c -> Cookeio.name c = name) cookies in
83
84 (* Test cookie-1: session cookie on exact domain *)
85 let cookie1 = find_cookie "cookie-1" in
86 Alcotest.(check string)
87 "cookie-1 domain" "example.com" (Cookeio.domain cookie1);
88 Alcotest.(check string) "cookie-1 path" "/foo/" (Cookeio.path cookie1);
89 Alcotest.(check string) "cookie-1 name" "cookie-1" (Cookeio.name cookie1);
90 Alcotest.(check string) "cookie-1 value" "v$1" (Cookeio.value cookie1);
91 Alcotest.(check bool) "cookie-1 secure" false (Cookeio.secure cookie1);
92 Alcotest.(check bool) "cookie-1 http_only" false (Cookeio.http_only cookie1);
93 Alcotest.(check (option expiration_testable))
94 "cookie-1 expires" None (Cookeio.expires cookie1);
95 Alcotest.(
96 check
97 (option
98 (Alcotest.testable
99 (fun ppf -> function
100 | `Strict -> Format.pp_print_string ppf "Strict"
101 | `Lax -> Format.pp_print_string ppf "Lax"
102 | `None -> Format.pp_print_string ppf "None")
103 ( = ))))
104 "cookie-1 same_site" None
105 (Cookeio.same_site cookie1);
106
107 (* Test cookie-2: session cookie on subdomain pattern *)
108 let cookie2 = find_cookie "cookie-2" in
109 Alcotest.(check string)
110 "cookie-2 domain" "example.com" (Cookeio.domain cookie2);
111 Alcotest.(check string) "cookie-2 path" "/foo/" (Cookeio.path cookie2);
112 Alcotest.(check string) "cookie-2 name" "cookie-2" (Cookeio.name cookie2);
113 Alcotest.(check string) "cookie-2 value" "v$2" (Cookeio.value cookie2);
114 Alcotest.(check bool) "cookie-2 secure" false (Cookeio.secure cookie2);
115 Alcotest.(check bool) "cookie-2 http_only" false (Cookeio.http_only cookie2);
116 Alcotest.(check (option expiration_testable))
117 "cookie-2 expires" None (Cookeio.expires cookie2);
118
119 (* Test cookie-3: non-session cookie with expiry *)
120 let cookie3 = find_cookie "cookie-3" in
121 let expected_expiry = Ptime.of_float_s 1257894000.0 in
122 Alcotest.(check string)
123 "cookie-3 domain" "example.com" (Cookeio.domain cookie3);
124 Alcotest.(check string) "cookie-3 path" "/foo/" (Cookeio.path cookie3);
125 Alcotest.(check string) "cookie-3 name" "cookie-3" (Cookeio.name cookie3);
126 Alcotest.(check string) "cookie-3 value" "v$3" (Cookeio.value cookie3);
127 Alcotest.(check bool) "cookie-3 secure" false (Cookeio.secure cookie3);
128 Alcotest.(check bool) "cookie-3 http_only" false (Cookeio.http_only cookie3);
129 begin match expected_expiry with
130 | Some t ->
131 Alcotest.(check (option expiration_testable))
132 "cookie-3 expires"
133 (Some (`DateTime t))
134 (Cookeio.expires cookie3)
135 | None -> Alcotest.fail "Expected expiry time for cookie-3"
136 end;
137
138 (* Test cookie-4: another non-session cookie *)
139 let cookie4 = find_cookie "cookie-4" in
140 Alcotest.(check string)
141 "cookie-4 domain" "example.com" (Cookeio.domain cookie4);
142 Alcotest.(check string) "cookie-4 path" "/foo/" (Cookeio.path cookie4);
143 Alcotest.(check string) "cookie-4 name" "cookie-4" (Cookeio.name cookie4);
144 Alcotest.(check string) "cookie-4 value" "v$4" (Cookeio.value cookie4);
145 Alcotest.(check bool) "cookie-4 secure" false (Cookeio.secure cookie4);
146 Alcotest.(check bool) "cookie-4 http_only" false (Cookeio.http_only cookie4);
147 begin match expected_expiry with
148 | Some t ->
149 Alcotest.(check (option expiration_testable))
150 "cookie-4 expires"
151 (Some (`DateTime t))
152 (Cookeio.expires cookie4)
153 | None -> Alcotest.fail "Expected expiry time for cookie-4"
154 end;
155
156 (* Test cookie-5: secure cookie *)
157 let cookie5 = find_cookie "cookie-5" in
158 Alcotest.(check string)
159 "cookie-5 domain" "example.com" (Cookeio.domain cookie5);
160 Alcotest.(check string) "cookie-5 path" "/foo/" (Cookeio.path cookie5);
161 Alcotest.(check string) "cookie-5 name" "cookie-5" (Cookeio.name cookie5);
162 Alcotest.(check string) "cookie-5 value" "v$5" (Cookeio.value cookie5);
163 Alcotest.(check bool) "cookie-5 secure" true (Cookeio.secure cookie5);
164 Alcotest.(check bool) "cookie-5 http_only" false (Cookeio.http_only cookie5);
165 begin match expected_expiry with
166 | Some t ->
167 Alcotest.(check (option expiration_testable))
168 "cookie-5 expires"
169 (Some (`DateTime t))
170 (Cookeio.expires cookie5)
171 | None -> Alcotest.fail "Expected expiry time for cookie-5"
172 end
173
174let test_load_from_file env =
175 (* This test loads from the actual test/cookies.txt file using the load function *)
176 let clock = Eio.Stdenv.clock env in
177 let cwd = Eio.Stdenv.cwd env in
178 let cookie_path = Eio.Path.(cwd / "cookies.txt") in
179 let jar = load ~clock cookie_path in
180 let cookies = get_all_cookies jar in
181
182 (* Should have the same 5 cookies as the string test *)
183 Alcotest.(check int) "file load cookie count" 5 (List.length cookies);
184
185 let find_cookie name = List.find (fun c -> Cookeio.name c = name) cookies in
186
187 (* Verify a few key cookies are loaded correctly *)
188 let cookie1 = find_cookie "cookie-1" in
189 Alcotest.(check string) "file cookie-1 value" "v$1" (Cookeio.value cookie1);
190 Alcotest.(check string)
191 "file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
192 Alcotest.(check bool) "file cookie-1 secure" false (Cookeio.secure cookie1);
193 Alcotest.(check (option expiration_testable))
194 "file cookie-1 expires" None (Cookeio.expires cookie1);
195
196 let cookie5 = find_cookie "cookie-5" in
197 Alcotest.(check string) "file cookie-5 value" "v$5" (Cookeio.value cookie5);
198 Alcotest.(check bool) "file cookie-5 secure" true (Cookeio.secure cookie5);
199 let expected_expiry = Ptime.of_float_s 1257894000.0 in
200 begin match expected_expiry with
201 | Some t ->
202 Alcotest.(check (option expiration_testable))
203 "file cookie-5 expires"
204 (Some (`DateTime t))
205 (Cookeio.expires cookie5)
206 | None -> Alcotest.fail "Expected expiry time for cookie-5"
207 end;
208
209 (* Verify subdomain cookie *)
210 let cookie2 = find_cookie "cookie-2" in
211 Alcotest.(check string)
212 "file cookie-2 domain" "example.com" (Cookeio.domain cookie2);
213 Alcotest.(check (option expiration_testable))
214 "file cookie-2 expires" None (Cookeio.expires cookie2)
215
216let test_cookie_matching env =
217 let clock = Eio.Stdenv.clock env in
218 let jar = create () in
219
220 (* Add test cookies with different domain patterns *)
221 let exact_cookie =
222 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"exact" ~value:"test1"
223 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
224 ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
225 in
226 let subdomain_cookie =
227 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"subdomain"
228 ~value:"test2" ~secure:false ~http_only:false ?expires:None
229 ?same_site:None ?max_age:None ~creation_time:Ptime.epoch
230 ~last_access:Ptime.epoch ()
231 in
232 let secure_cookie =
233 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"secure" ~value:"test3"
234 ~secure:true ~http_only:false ?expires:None ?same_site:None ?max_age:None
235 ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
236 in
237
238 add_cookie jar exact_cookie;
239 add_cookie jar subdomain_cookie;
240 add_cookie jar secure_cookie;
241
242 (* Test exact domain matching - all three cookies should match example.com *)
243 let cookies_http =
244 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
245 in
246 Alcotest.(check int) "http cookies count" 2 (List.length cookies_http);
247
248 let cookies_https =
249 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:true
250 in
251 Alcotest.(check int) "https cookies count" 3 (List.length cookies_https);
252
253 (* Test subdomain matching - all cookies should match subdomains now *)
254 let cookies_sub =
255 get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
256 in
257 Alcotest.(check int) "subdomain cookies count" 2 (List.length cookies_sub)
258
259let test_empty_jar env =
260 let clock = Eio.Stdenv.clock env in
261 let jar = create () in
262 Alcotest.(check bool) "empty jar" true (is_empty jar);
263 Alcotest.(check int) "empty count" 0 (count jar);
264 Alcotest.(check (list cookie_testable))
265 "empty cookies" [] (get_all_cookies jar);
266
267 let cookies =
268 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
269 in
270 Alcotest.(check int) "no matching cookies" 0 (List.length cookies)
271
272let test_round_trip_mozilla_format env =
273 let clock = Eio.Stdenv.clock env in
274 let jar = create () in
275
276 let test_cookie =
277 let expires =
278 match Ptime.of_float_s 1257894000.0 with
279 | Some t -> Some (`DateTime t)
280 | None -> None
281 in
282 Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test"
283 ~value:"value" ~secure:true ~http_only:false ?expires ~same_site:`Strict
284 ?max_age:None ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
285 in
286
287 add_cookie jar test_cookie;
288
289 (* Convert to Mozilla format and back *)
290 let mozilla_format = to_mozilla_format jar in
291 let jar2 = from_mozilla_format ~clock mozilla_format in
292 let cookies2 = get_all_cookies jar2 in
293
294 Alcotest.(check int) "round trip count" 1 (List.length cookies2);
295 let cookie2 = List.hd cookies2 in
296 Alcotest.(check string) "round trip name" "test" (Cookeio.name cookie2);
297 Alcotest.(check string) "round trip value" "value" (Cookeio.value cookie2);
298 Alcotest.(check string)
299 "round trip domain" "example.com" (Cookeio.domain cookie2);
300 Alcotest.(check string) "round trip path" "/test/" (Cookeio.path cookie2);
301 Alcotest.(check bool) "round trip secure" true (Cookeio.secure cookie2);
302 (* Note: http_only and same_site are lost in Mozilla format *)
303 begin match Ptime.of_float_s 1257894000.0 with
304 | Some t ->
305 Alcotest.(check (option expiration_testable))
306 "round trip expires"
307 (Some (`DateTime t))
308 (Cookeio.expires cookie2)
309 | None -> Alcotest.fail "Expected expiry time"
310 end
311
312let test_cookie_expiry_with_mock_clock () =
313 Eio_mock.Backend.run @@ fun () ->
314 let clock = Eio_mock.Clock.make () in
315
316 (* Start at time 1000.0 for convenience *)
317 Eio_mock.Clock.set_time clock 1000.0;
318
319 let jar = create () in
320
321 (* Add a cookie that expires at time 1500.0 (expires in 500 seconds) *)
322 let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in
323 let cookie1 =
324 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon"
325 ~value:"value1" ~secure:false ~http_only:false
326 ~expires:(`DateTime expires_soon) ?same_site:None ?max_age:None
327 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
328 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
329 ()
330 in
331
332 (* Add a cookie that expires at time 2000.0 (expires in 1000 seconds) *)
333 let expires_later = Ptime.of_float_s 2000.0 |> Option.get in
334 let cookie2 =
335 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later"
336 ~value:"value2" ~secure:false ~http_only:false
337 ~expires:(`DateTime expires_later) ?same_site:None ?max_age:None
338 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
339 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
340 ()
341 in
342
343 (* Add a session cookie (no expiry) *)
344 let cookie3 =
345 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"value3"
346 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
347 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
348 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
349 ()
350 in
351
352 add_cookie jar cookie1;
353 add_cookie jar cookie2;
354 add_cookie jar cookie3;
355
356 Alcotest.(check int) "initial count" 3 (count jar);
357
358 (* Advance time to 1600.0 - first cookie should expire *)
359 Eio_mock.Clock.set_time clock 1600.0;
360 clear_expired jar ~clock;
361
362 Alcotest.(check int) "after first expiry" 2 (count jar);
363
364 let cookies = get_all_cookies jar in
365 let names = List.map Cookeio.name cookies |> List.sort String.compare in
366 Alcotest.(check (list string))
367 "remaining cookies after 1600s"
368 [ "expires_later"; "session" ]
369 names;
370
371 (* Advance time to 2100.0 - second cookie should expire *)
372 Eio_mock.Clock.set_time clock 2100.0;
373 clear_expired jar ~clock;
374
375 Alcotest.(check int) "after second expiry" 1 (count jar);
376
377 let remaining = get_all_cookies jar in
378 Alcotest.(check string)
379 "only session cookie remains" "session"
380 (Cookeio.name (List.hd remaining))
381
382let test_get_cookies_filters_expired () =
383 Eio_mock.Backend.run @@ fun () ->
384 let clock = Eio_mock.Clock.make () in
385 Eio_mock.Clock.set_time clock 1000.0;
386
387 let jar = create () in
388
389 (* Add an expired cookie (expired at time 500) *)
390 let expired = Ptime.of_float_s 500.0 |> Option.get in
391 let cookie_expired =
392 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expired"
393 ~value:"old" ~secure:false ~http_only:false
394 ~expires:(`DateTime expired)
395 ~creation_time:(Ptime.of_float_s 100.0 |> Option.get)
396 ~last_access:(Ptime.of_float_s 100.0 |> Option.get)
397 ()
398 in
399
400 (* Add a valid cookie (expires at time 2000) *)
401 let valid_time = Ptime.of_float_s 2000.0 |> Option.get in
402 let cookie_valid =
403 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"valid"
404 ~value:"current" ~secure:false ~http_only:false
405 ~expires:(`DateTime valid_time)
406 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
407 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
408 ()
409 in
410
411 (* Add a session cookie (no expiry) *)
412 let cookie_session =
413 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session"
414 ~value:"sess" ~secure:false ~http_only:false
415 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
416 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
417 ()
418 in
419
420 add_cookie jar cookie_expired;
421 add_cookie jar cookie_valid;
422 add_cookie jar cookie_session;
423
424 (* get_all_cookies returns all including expired (for inspection) *)
425 Alcotest.(check int) "get_all_cookies includes expired" 3
426 (List.length (get_all_cookies jar));
427
428 (* get_cookies should automatically filter out expired cookies *)
429 let cookies =
430 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
431 in
432 Alcotest.(check int) "get_cookies filters expired" 2 (List.length cookies);
433
434 let names = List.map Cookeio.name cookies |> List.sort String.compare in
435 Alcotest.(check (list string))
436 "only non-expired cookies returned"
437 [ "session"; "valid" ]
438 names
439
440let test_max_age_parsing_with_mock_clock () =
441 Eio_mock.Backend.run @@ fun () ->
442 let clock = Eio_mock.Clock.make () in
443
444 (* Start at a known time *)
445 Eio_mock.Clock.set_time clock 5000.0;
446
447 (* Parse a Set-Cookie header with Max-Age *)
448 let header = "session=abc123; Max-Age=3600; Secure; HttpOnly" in
449 let cookie_opt =
450 of_set_cookie_header
451 ~now:(fun () ->
452 Ptime.of_float_s (Eio.Time.now clock)
453 |> Option.value ~default:Ptime.epoch)
454 ~domain:"example.com" ~path:"/" header
455 in
456
457 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
458
459 let cookie = Result.get_ok cookie_opt in
460 Alcotest.(check string) "cookie name" "session" (Cookeio.name cookie);
461 Alcotest.(check string) "cookie value" "abc123" (Cookeio.value cookie);
462 Alcotest.(check bool) "cookie secure" true (Cookeio.secure cookie);
463 Alcotest.(check bool) "cookie http_only" true (Cookeio.http_only cookie);
464
465 (* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *)
466 let expected_expiry = Ptime.of_float_s 8600.0 in
467 begin match expected_expiry with
468 | Some t ->
469 Alcotest.(check (option expiration_testable))
470 "expires set from max-age"
471 (Some (`DateTime t))
472 (Cookeio.expires cookie)
473 | None -> Alcotest.fail "Expected expiry time"
474 end;
475
476 (* Verify creation time matches clock time *)
477 let expected_creation = Ptime.of_float_s 5000.0 in
478 Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
479 "creation time" expected_creation
480 (Some (Cookeio.creation_time cookie))
481
482let test_last_access_time_with_mock_clock () =
483 Eio_mock.Backend.run @@ fun () ->
484 let clock = Eio_mock.Clock.make () in
485
486 (* Start at time 3000.0 *)
487 Eio_mock.Clock.set_time clock 3000.0;
488
489 let jar = create () in
490
491 (* Add a cookie *)
492 let cookie =
493 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
494 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
495 ~creation_time:(Ptime.of_float_s 3000.0 |> Option.get)
496 ~last_access:(Ptime.of_float_s 3000.0 |> Option.get)
497 ()
498 in
499 add_cookie jar cookie;
500
501 (* Verify initial last access time *)
502 let cookies1 = get_all_cookies jar in
503 let cookie1 = List.hd cookies1 in
504 Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
505 "initial last access" (Ptime.of_float_s 3000.0)
506 (Some (Cookeio.last_access cookie1));
507
508 (* Advance time to 4000.0 *)
509 Eio_mock.Clock.set_time clock 4000.0;
510
511 (* Get cookies, which should update last access time to current clock time *)
512 let _retrieved =
513 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
514 in
515
516 (* Verify last access time was updated to the new clock time *)
517 let cookies2 = get_all_cookies jar in
518 let cookie2 = List.hd cookies2 in
519 Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
520 "updated last access" (Ptime.of_float_s 4000.0)
521 (Some (Cookeio.last_access cookie2))
522
523let test_of_set_cookie_header_with_expires () =
524 Eio_mock.Backend.run @@ fun () ->
525 let clock = Eio_mock.Clock.make () in
526
527 (* Start at a known time *)
528 Eio_mock.Clock.set_time clock 6000.0;
529
530 (* Use RFC3339 format which is what Ptime.of_rfc3339 expects *)
531 let header =
532 "id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com"
533 in
534 let cookie_opt =
535 of_set_cookie_header
536 ~now:(fun () ->
537 Ptime.of_float_s (Eio.Time.now clock)
538 |> Option.value ~default:Ptime.epoch)
539 ~domain:"example.com" ~path:"/" header
540 in
541
542 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
543
544 let cookie = Result.get_ok cookie_opt in
545 Alcotest.(check string) "cookie name" "id" (Cookeio.name cookie);
546 Alcotest.(check string) "cookie value" "xyz789" (Cookeio.value cookie);
547 Alcotest.(check string) "cookie domain" "example.com" (Cookeio.domain cookie);
548 Alcotest.(check string) "cookie path" "/" (Cookeio.path cookie);
549
550 (* Verify expires is parsed correctly *)
551 Alcotest.(check bool)
552 "has expiry" true
553 (Option.is_some (Cookeio.expires cookie));
554
555 (* Verify the specific expiry time parsed from the RFC3339 date *)
556 let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
557 match expected_expiry with
558 | Ok (time, _, _) ->
559 Alcotest.(check (option expiration_testable))
560 "expires matches parsed value"
561 (Some (`DateTime time))
562 (Cookeio.expires cookie)
563 | Error _ -> Alcotest.fail "Failed to parse expected expiry time"
564
565let test_samesite_none_validation () =
566 Eio_mock.Backend.run @@ fun () ->
567 let clock = Eio_mock.Clock.make () in
568
569 (* Start at a known time *)
570 Eio_mock.Clock.set_time clock 7000.0;
571
572 (* This should be rejected: SameSite=None without Secure *)
573 let invalid_header = "token=abc; SameSite=None" in
574 let cookie_opt =
575 of_set_cookie_header
576 ~now:(fun () ->
577 Ptime.of_float_s (Eio.Time.now clock)
578 |> Option.value ~default:Ptime.epoch)
579 ~domain:"example.com" ~path:"/" invalid_header
580 in
581
582 Alcotest.(check bool)
583 "invalid cookie rejected" true
584 (Result.is_error cookie_opt);
585
586 (* This should be accepted: SameSite=None with Secure *)
587 let valid_header = "token=abc; SameSite=None; Secure" in
588 let cookie_opt2 =
589 of_set_cookie_header
590 ~now:(fun () ->
591 Ptime.of_float_s (Eio.Time.now clock)
592 |> Option.value ~default:Ptime.epoch)
593 ~domain:"example.com" ~path:"/" valid_header
594 in
595
596 Alcotest.(check bool)
597 "valid cookie accepted" true
598 (Result.is_ok cookie_opt2);
599
600 let cookie = Result.get_ok cookie_opt2 in
601 Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie);
602 Alcotest.(
603 check
604 (option
605 (Alcotest.testable
606 (fun ppf -> function
607 | `Strict -> Format.pp_print_string ppf "Strict"
608 | `Lax -> Format.pp_print_string ppf "Lax"
609 | `None -> Format.pp_print_string ppf "None")
610 ( = ))))
611 "samesite is None" (Some `None) (Cookeio.same_site cookie)
612
613let test_domain_normalization () =
614 Eio_mock.Backend.run @@ fun () ->
615 let clock = Eio_mock.Clock.make () in
616 Eio_mock.Clock.set_time clock 1000.0;
617
618 (* Test parsing ".example.com" stores as "example.com" *)
619 let header = "test=value; Domain=.example.com" in
620 let cookie_opt =
621 of_set_cookie_header
622 ~now:(fun () ->
623 Ptime.of_float_s (Eio.Time.now clock)
624 |> Option.value ~default:Ptime.epoch)
625 ~domain:"example.com" ~path:"/" header
626 in
627 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
628 let cookie = Result.get_ok cookie_opt in
629 Alcotest.(check string)
630 "domain normalized" "example.com" (Cookeio.domain cookie);
631
632 (* Test round-trip through Mozilla format normalizes domains *)
633 let jar = create () in
634 let test_cookie =
635 Cookeio.make ~domain:".example.com" ~path:"/" ~name:"test" ~value:"val"
636 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
637 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
638 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
639 ()
640 in
641 add_cookie jar test_cookie;
642
643 let mozilla_format = to_mozilla_format jar in
644 let jar2 = from_mozilla_format ~clock mozilla_format in
645 let cookies2 = get_all_cookies jar2 in
646 Alcotest.(check int) "one cookie" 1 (List.length cookies2);
647 Alcotest.(check string)
648 "domain normalized after round-trip" "example.com"
649 (Cookeio.domain (List.hd cookies2))
650
651let test_max_age_stored_separately () =
652 Eio_mock.Backend.run @@ fun () ->
653 let clock = Eio_mock.Clock.make () in
654 Eio_mock.Clock.set_time clock 5000.0;
655
656 (* Parse a Set-Cookie header with Max-Age *)
657 let header = "session=abc123; Max-Age=3600" in
658 let cookie_opt =
659 of_set_cookie_header
660 ~now:(fun () ->
661 Ptime.of_float_s (Eio.Time.now clock)
662 |> Option.value ~default:Ptime.epoch)
663 ~domain:"example.com" ~path:"/" header
664 in
665 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
666
667 let cookie = Result.get_ok cookie_opt in
668
669 (* Verify max_age is stored as a Ptime.Span *)
670 Alcotest.(check bool)
671 "max_age is set" true
672 (Option.is_some (Cookeio.max_age cookie));
673 let max_age_span = Option.get (Cookeio.max_age cookie) in
674 Alcotest.(check (option int))
675 "max_age is 3600 seconds" (Some 3600)
676 (Ptime.Span.to_int_s max_age_span);
677
678 (* Verify expires is also computed correctly *)
679 let expected_expiry = Ptime.of_float_s 8600.0 in
680 begin match expected_expiry with
681 | Some t ->
682 Alcotest.(check (option expiration_testable))
683 "expires computed from max-age"
684 (Some (`DateTime t))
685 (Cookeio.expires cookie)
686 | None -> Alcotest.fail "Expected expiry time"
687 end
688
689let test_max_age_negative_becomes_zero () =
690 Eio_mock.Backend.run @@ fun () ->
691 let clock = Eio_mock.Clock.make () in
692 Eio_mock.Clock.set_time clock 5000.0;
693
694 (* Parse a Set-Cookie header with negative Max-Age *)
695 let header = "session=abc123; Max-Age=-100" in
696 let cookie_opt =
697 of_set_cookie_header
698 ~now:(fun () ->
699 Ptime.of_float_s (Eio.Time.now clock)
700 |> Option.value ~default:Ptime.epoch)
701 ~domain:"example.com" ~path:"/" header
702 in
703 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
704
705 let cookie = Result.get_ok cookie_opt in
706
707 (* Verify max_age is stored as 0 per RFC 6265 *)
708 Alcotest.(check bool)
709 "max_age is set" true
710 (Option.is_some (Cookeio.max_age cookie));
711 let max_age_span = Option.get (Cookeio.max_age cookie) in
712 Alcotest.(check (option int))
713 "negative max_age becomes 0" (Some 0)
714 (Ptime.Span.to_int_s max_age_span);
715
716 (* Verify expires is computed with 0 seconds *)
717 let expected_expiry = Ptime.of_float_s 5000.0 in
718 begin match expected_expiry with
719 | Some t ->
720 Alcotest.(check (option expiration_testable))
721 "expires computed with 0 seconds"
722 (Some (`DateTime t))
723 (Cookeio.expires cookie)
724 | None -> Alcotest.fail "Expected expiry time"
725 end
726
727let string_contains_substring s sub =
728 try
729 let len = String.length sub in
730 let rec search i =
731 if i + len > String.length s then false
732 else if String.sub s i len = sub then true
733 else search (i + 1)
734 in
735 search 0
736 with _ -> false
737
738let test_make_set_cookie_header_includes_max_age () =
739 Eio_mock.Backend.run @@ fun () ->
740 let clock = Eio_mock.Clock.make () in
741 Eio_mock.Clock.set_time clock 5000.0;
742
743 (* Create a cookie with max_age *)
744 let max_age_span = Ptime.Span.of_int_s 3600 in
745 let expires_time = Ptime.of_float_s 8600.0 |> Option.get in
746 let cookie =
747 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"abc123"
748 ~secure:true ~http_only:true
749 ?expires:(Some (`DateTime expires_time))
750 ?max_age:(Some max_age_span) ?same_site:(Some `Strict)
751 ~creation_time:(Ptime.of_float_s 5000.0 |> Option.get)
752 ~last_access:(Ptime.of_float_s 5000.0 |> Option.get)
753 ()
754 in
755
756 let header = make_set_cookie_header cookie in
757
758 (* Verify the header includes Max-Age *)
759 Alcotest.(check bool)
760 "header includes Max-Age" true
761 (string_contains_substring header "Max-Age=3600");
762
763 (* Verify the header includes Expires *)
764 Alcotest.(check bool)
765 "header includes Expires" true
766 (string_contains_substring header "Expires=");
767
768 (* Verify the header includes other attributes *)
769 Alcotest.(check bool)
770 "header includes Secure" true
771 (string_contains_substring header "Secure");
772 Alcotest.(check bool)
773 "header includes HttpOnly" true
774 (string_contains_substring header "HttpOnly");
775 Alcotest.(check bool)
776 "header includes SameSite" true
777 (string_contains_substring header "SameSite=Strict")
778
779let test_max_age_round_trip () =
780 Eio_mock.Backend.run @@ fun () ->
781 let clock = Eio_mock.Clock.make () in
782 Eio_mock.Clock.set_time clock 5000.0;
783
784 (* Parse a cookie with Max-Age *)
785 let header = "session=xyz; Max-Age=7200; Secure; HttpOnly" in
786 let cookie_opt =
787 of_set_cookie_header
788 ~now:(fun () ->
789 Ptime.of_float_s (Eio.Time.now clock)
790 |> Option.value ~default:Ptime.epoch)
791 ~domain:"example.com" ~path:"/" header
792 in
793 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
794 let cookie = Result.get_ok cookie_opt in
795
796 (* Generate Set-Cookie header from the cookie *)
797 let set_cookie_header = make_set_cookie_header cookie in
798
799 (* Parse it back *)
800 Eio_mock.Clock.set_time clock 5000.0;
801 (* Reset clock to same time *)
802 let cookie2_opt =
803 of_set_cookie_header
804 ~now:(fun () ->
805 Ptime.of_float_s (Eio.Time.now clock)
806 |> Option.value ~default:Ptime.epoch)
807 ~domain:"example.com" ~path:"/" set_cookie_header
808 in
809 Alcotest.(check bool) "cookie re-parsed" true (Result.is_ok cookie2_opt);
810 let cookie2 = Result.get_ok cookie2_opt in
811
812 (* Verify max_age is preserved *)
813 Alcotest.(check (option int))
814 "max_age preserved"
815 (Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie)))
816 (Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie2)))
817
818let test_domain_matching () =
819 Eio_mock.Backend.run @@ fun () ->
820 let clock = Eio_mock.Clock.make () in
821 Eio_mock.Clock.set_time clock 2000.0;
822
823 let jar = create () in
824
825 (* Create a cookie with domain "example.com" *)
826 let cookie =
827 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
828 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
829 ~creation_time:(Ptime.of_float_s 2000.0 |> Option.get)
830 ~last_access:(Ptime.of_float_s 2000.0 |> Option.get)
831 ()
832 in
833 add_cookie jar cookie;
834
835 (* Test "example.com" cookie matches "example.com" request *)
836 let cookies1 =
837 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
838 in
839 Alcotest.(check int) "matches exact domain" 1 (List.length cookies1);
840
841 (* Test "example.com" cookie matches "sub.example.com" request *)
842 let cookies2 =
843 get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
844 in
845 Alcotest.(check int) "matches subdomain" 1 (List.length cookies2);
846
847 (* Test "example.com" cookie matches "deep.sub.example.com" request *)
848 let cookies3 =
849 get_cookies jar ~clock ~domain:"deep.sub.example.com" ~path:"/"
850 ~is_secure:false
851 in
852 Alcotest.(check int) "matches deep subdomain" 1 (List.length cookies3);
853
854 (* Test "example.com" cookie doesn't match "notexample.com" *)
855 let cookies4 =
856 get_cookies jar ~clock ~domain:"notexample.com" ~path:"/" ~is_secure:false
857 in
858 Alcotest.(check int) "doesn't match different domain" 0 (List.length cookies4);
859
860 (* Test "example.com" cookie doesn't match "fakeexample.com" *)
861 let cookies5 =
862 get_cookies jar ~clock ~domain:"fakeexample.com" ~path:"/" ~is_secure:false
863 in
864 Alcotest.(check int) "doesn't match prefix domain" 0 (List.length cookies5)
865
866(** {1 HTTP Date Parsing Tests} *)
867
868let test_http_date_fmt1 () =
869 Eio_mock.Backend.run @@ fun () ->
870 let clock = Eio_mock.Clock.make () in
871 Eio_mock.Clock.set_time clock 1000.0;
872
873 (* Test FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
874 let header = "session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT" in
875 let cookie_opt =
876 of_set_cookie_header
877 ~now:(fun () ->
878 Ptime.of_float_s (Eio.Time.now clock)
879 |> Option.value ~default:Ptime.epoch)
880 ~domain:"example.com" ~path:"/" header
881 in
882 Alcotest.(check bool) "FMT1 cookie parsed" true (Result.is_ok cookie_opt);
883
884 let cookie = Result.get_ok cookie_opt in
885 Alcotest.(check bool)
886 "FMT1 has expiry" true
887 (Option.is_some (Cookeio.expires cookie));
888
889 (* Verify the parsed time matches expected value *)
890 let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
891 begin match expected with
892 | Some t ->
893 Alcotest.(check (option expiration_testable))
894 "FMT1 expiry correct"
895 (Some (`DateTime t))
896 (Cookeio.expires cookie)
897 | None -> Alcotest.fail "Expected expiry time for FMT1"
898 end
899
900let test_http_date_fmt2 () =
901 Eio_mock.Backend.run @@ fun () ->
902 let clock = Eio_mock.Clock.make () in
903 Eio_mock.Clock.set_time clock 1000.0;
904
905 (* Test FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850 with abbreviated year) *)
906 let header = "session=abc; Expires=Wednesday, 21-Oct-15 07:28:00 GMT" in
907 let cookie_opt =
908 of_set_cookie_header
909 ~now:(fun () ->
910 Ptime.of_float_s (Eio.Time.now clock)
911 |> Option.value ~default:Ptime.epoch)
912 ~domain:"example.com" ~path:"/" header
913 in
914 Alcotest.(check bool) "FMT2 cookie parsed" true (Result.is_ok cookie_opt);
915
916 let cookie = Result.get_ok cookie_opt in
917 Alcotest.(check bool)
918 "FMT2 has expiry" true
919 (Option.is_some (Cookeio.expires cookie));
920
921 (* Year 15 should be normalized to 2015 *)
922 let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
923 begin match expected with
924 | Some t ->
925 Alcotest.(check (option expiration_testable))
926 "FMT2 expiry correct with year normalization"
927 (Some (`DateTime t))
928 (Cookeio.expires cookie)
929 | None -> Alcotest.fail "Expected expiry time for FMT2"
930 end
931
932let test_http_date_fmt3 () =
933 Eio_mock.Backend.run @@ fun () ->
934 let clock = Eio_mock.Clock.make () in
935 Eio_mock.Clock.set_time clock 1000.0;
936
937 (* Test FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
938 let header = "session=abc; Expires=Wed Oct 21 07:28:00 2015" in
939 let cookie_opt =
940 of_set_cookie_header
941 ~now:(fun () ->
942 Ptime.of_float_s (Eio.Time.now clock)
943 |> Option.value ~default:Ptime.epoch)
944 ~domain:"example.com" ~path:"/" header
945 in
946 Alcotest.(check bool) "FMT3 cookie parsed" true (Result.is_ok cookie_opt);
947
948 let cookie = Result.get_ok cookie_opt in
949 Alcotest.(check bool)
950 "FMT3 has expiry" true
951 (Option.is_some (Cookeio.expires cookie));
952
953 let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
954 begin match expected with
955 | Some t ->
956 Alcotest.(check (option expiration_testable))
957 "FMT3 expiry correct"
958 (Some (`DateTime t))
959 (Cookeio.expires cookie)
960 | None -> Alcotest.fail "Expected expiry time for FMT3"
961 end
962
963let test_http_date_fmt4 () =
964 Eio_mock.Backend.run @@ fun () ->
965 let clock = Eio_mock.Clock.make () in
966 Eio_mock.Clock.set_time clock 1000.0;
967
968 (* Test FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
969 let header = "session=abc; Expires=Wed, 21-Oct-2015 07:28:00 GMT" in
970 let cookie_opt =
971 of_set_cookie_header
972 ~now:(fun () ->
973 Ptime.of_float_s (Eio.Time.now clock)
974 |> Option.value ~default:Ptime.epoch)
975 ~domain:"example.com" ~path:"/" header
976 in
977 Alcotest.(check bool) "FMT4 cookie parsed" true (Result.is_ok cookie_opt);
978
979 let cookie = Result.get_ok cookie_opt in
980 Alcotest.(check bool)
981 "FMT4 has expiry" true
982 (Option.is_some (Cookeio.expires cookie));
983
984 let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
985 begin match expected with
986 | Some t ->
987 Alcotest.(check (option expiration_testable))
988 "FMT4 expiry correct"
989 (Some (`DateTime t))
990 (Cookeio.expires cookie)
991 | None -> Alcotest.fail "Expected expiry time for FMT4"
992 end
993
994let test_abbreviated_year_69_to_99 () =
995 Eio_mock.Backend.run @@ fun () ->
996 let clock = Eio_mock.Clock.make () in
997 Eio_mock.Clock.set_time clock 1000.0;
998
999 (* Year 95 should become 1995 *)
1000 let header = "session=abc; Expires=Wed, 21-Oct-95 07:28:00 GMT" in
1001 let cookie_opt =
1002 of_set_cookie_header
1003 ~now:(fun () ->
1004 Ptime.of_float_s (Eio.Time.now clock)
1005 |> Option.value ~default:Ptime.epoch)
1006 ~domain:"example.com" ~path:"/" header
1007 in
1008 let cookie = Result.get_ok cookie_opt in
1009 let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
1010 begin match expected with
1011 | Some t ->
1012 Alcotest.(check (option expiration_testable))
1013 "year 95 becomes 1995"
1014 (Some (`DateTime t))
1015 (Cookeio.expires cookie)
1016 | None -> Alcotest.fail "Expected expiry time for year 95"
1017 end;
1018
1019 (* Year 69 should become 1969 *)
1020 let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in
1021 let cookie_opt2 =
1022 of_set_cookie_header
1023 ~now:(fun () ->
1024 Ptime.of_float_s (Eio.Time.now clock)
1025 |> Option.value ~default:Ptime.epoch)
1026 ~domain:"example.com" ~path:"/" header2
1027 in
1028 let cookie2 = Result.get_ok cookie_opt2 in
1029 let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
1030 begin match expected2 with
1031 | Some t ->
1032 Alcotest.(check (option expiration_testable))
1033 "year 69 becomes 1969"
1034 (Some (`DateTime t))
1035 (Cookeio.expires cookie2)
1036 | None -> Alcotest.fail "Expected expiry time for year 69"
1037 end;
1038
1039 (* Year 99 should become 1999 *)
1040 let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in
1041 let cookie_opt3 =
1042 of_set_cookie_header
1043 ~now:(fun () ->
1044 Ptime.of_float_s (Eio.Time.now clock)
1045 |> Option.value ~default:Ptime.epoch)
1046 ~domain:"example.com" ~path:"/" header3
1047 in
1048 let cookie3 = Result.get_ok cookie_opt3 in
1049 let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
1050 begin match expected3 with
1051 | Some t ->
1052 Alcotest.(check (option expiration_testable))
1053 "year 99 becomes 1999"
1054 (Some (`DateTime t))
1055 (Cookeio.expires cookie3)
1056 | None -> Alcotest.fail "Expected expiry time for year 99"
1057 end
1058
1059let test_abbreviated_year_0_to_68 () =
1060 Eio_mock.Backend.run @@ fun () ->
1061 let clock = Eio_mock.Clock.make () in
1062 Eio_mock.Clock.set_time clock 1000.0;
1063
1064 (* Year 25 should become 2025 *)
1065 let header = "session=abc; Expires=Wed, 21-Oct-25 07:28:00 GMT" in
1066 let cookie_opt =
1067 of_set_cookie_header
1068 ~now:(fun () ->
1069 Ptime.of_float_s (Eio.Time.now clock)
1070 |> Option.value ~default:Ptime.epoch)
1071 ~domain:"example.com" ~path:"/" header
1072 in
1073 let cookie = Result.get_ok cookie_opt in
1074 let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
1075 begin match expected with
1076 | Some t ->
1077 Alcotest.(check (option expiration_testable))
1078 "year 25 becomes 2025"
1079 (Some (`DateTime t))
1080 (Cookeio.expires cookie)
1081 | None -> Alcotest.fail "Expected expiry time for year 25"
1082 end;
1083
1084 (* Year 0 should become 2000 *)
1085 let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in
1086 let cookie_opt2 =
1087 of_set_cookie_header
1088 ~now:(fun () ->
1089 Ptime.of_float_s (Eio.Time.now clock)
1090 |> Option.value ~default:Ptime.epoch)
1091 ~domain:"example.com" ~path:"/" header2
1092 in
1093 let cookie2 = Result.get_ok cookie_opt2 in
1094 let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
1095 begin match expected2 with
1096 | Some t ->
1097 Alcotest.(check (option expiration_testable))
1098 "year 0 becomes 2000"
1099 (Some (`DateTime t))
1100 (Cookeio.expires cookie2)
1101 | None -> Alcotest.fail "Expected expiry time for year 0"
1102 end;
1103
1104 (* Year 68 should become 2068 *)
1105 let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in
1106 let cookie_opt3 =
1107 of_set_cookie_header
1108 ~now:(fun () ->
1109 Ptime.of_float_s (Eio.Time.now clock)
1110 |> Option.value ~default:Ptime.epoch)
1111 ~domain:"example.com" ~path:"/" header3
1112 in
1113 let cookie3 = Result.get_ok cookie_opt3 in
1114 let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
1115 begin match expected3 with
1116 | Some t ->
1117 Alcotest.(check (option expiration_testable))
1118 "year 68 becomes 2068"
1119 (Some (`DateTime t))
1120 (Cookeio.expires cookie3)
1121 | None -> Alcotest.fail "Expected expiry time for year 68"
1122 end
1123
1124let test_rfc3339_still_works () =
1125 Eio_mock.Backend.run @@ fun () ->
1126 let clock = Eio_mock.Clock.make () in
1127 Eio_mock.Clock.set_time clock 1000.0;
1128
1129 (* Ensure RFC 3339 format still works for backward compatibility *)
1130 let header = "session=abc; Expires=2025-10-21T07:28:00Z" in
1131 let cookie_opt =
1132 of_set_cookie_header
1133 ~now:(fun () ->
1134 Ptime.of_float_s (Eio.Time.now clock)
1135 |> Option.value ~default:Ptime.epoch)
1136 ~domain:"example.com" ~path:"/" header
1137 in
1138 Alcotest.(check bool)
1139 "RFC 3339 cookie parsed" true
1140 (Result.is_ok cookie_opt);
1141
1142 let cookie = Result.get_ok cookie_opt in
1143 Alcotest.(check bool)
1144 "RFC 3339 has expiry" true
1145 (Option.is_some (Cookeio.expires cookie));
1146
1147 (* Verify the time was parsed correctly *)
1148 let expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
1149 match expected with
1150 | Ok (time, _, _) ->
1151 Alcotest.(check (option expiration_testable))
1152 "RFC 3339 expiry correct"
1153 (Some (`DateTime time))
1154 (Cookeio.expires cookie)
1155 | Error _ -> Alcotest.fail "Failed to parse expected RFC 3339 time"
1156
1157let test_invalid_date_format_logs_warning () =
1158 Eio_mock.Backend.run @@ fun () ->
1159 let clock = Eio_mock.Clock.make () in
1160 Eio_mock.Clock.set_time clock 1000.0;
1161
1162 (* Invalid date format should log a warning but still parse the cookie *)
1163 let header = "session=abc; Expires=InvalidDate" in
1164 let cookie_opt =
1165 of_set_cookie_header
1166 ~now:(fun () ->
1167 Ptime.of_float_s (Eio.Time.now clock)
1168 |> Option.value ~default:Ptime.epoch)
1169 ~domain:"example.com" ~path:"/" header
1170 in
1171
1172 (* Cookie should still be parsed, just without expires *)
1173 Alcotest.(check bool)
1174 "cookie parsed despite invalid date" true
1175 (Result.is_ok cookie_opt);
1176 let cookie = Result.get_ok cookie_opt in
1177 Alcotest.(check string) "cookie name correct" "session" (Cookeio.name cookie);
1178 Alcotest.(check string) "cookie value correct" "abc" (Cookeio.value cookie);
1179 (* expires should be None since date was invalid *)
1180 Alcotest.(check (option expiration_testable))
1181 "expires is None for invalid date" None (Cookeio.expires cookie)
1182
1183let test_case_insensitive_month_parsing () =
1184 Eio_mock.Backend.run @@ fun () ->
1185 let clock = Eio_mock.Clock.make () in
1186 Eio_mock.Clock.set_time clock 1000.0;
1187
1188 (* Test various case combinations for month names *)
1189 let test_cases =
1190 [
1191 ("session=abc; Expires=Wed, 21 oct 2015 07:28:00 GMT", "lowercase month");
1192 ("session=abc; Expires=Wed, 21 OCT 2015 07:28:00 GMT", "uppercase month");
1193 ("session=abc; Expires=Wed, 21 OcT 2015 07:28:00 GMT", "mixed case month");
1194 ("session=abc; Expires=Wed, 21 oCt 2015 07:28:00 GMT", "weird case month");
1195 ]
1196 in
1197
1198 List.iter
1199 (fun (header, description) ->
1200 let cookie_opt =
1201 of_set_cookie_header
1202 ~now:(fun () ->
1203 Ptime.of_float_s (Eio.Time.now clock)
1204 |> Option.value ~default:Ptime.epoch)
1205 ~domain:"example.com" ~path:"/" header
1206 in
1207 Alcotest.(check bool)
1208 (description ^ " parsed") true
1209 (Result.is_ok cookie_opt);
1210
1211 let cookie = Result.get_ok cookie_opt in
1212 Alcotest.(check bool)
1213 (description ^ " has expiry")
1214 true
1215 (Option.is_some (Cookeio.expires cookie));
1216
1217 (* Verify the date was parsed correctly regardless of case *)
1218 let expires = Option.get (Cookeio.expires cookie) in
1219 match expires with
1220 | `DateTime ptime ->
1221 let year, month, _ = Ptime.to_date ptime in
1222 Alcotest.(check int) (description ^ " year correct") 2015 year;
1223 Alcotest.(check int)
1224 (description ^ " month correct (October=10)")
1225 10 month
1226 | `Session -> Alcotest.fail (description ^ " should not be session cookie"))
1227 test_cases
1228
1229let test_case_insensitive_gmt_parsing () =
1230 Eio_mock.Backend.run @@ fun () ->
1231 let clock = Eio_mock.Clock.make () in
1232 Eio_mock.Clock.set_time clock 1000.0;
1233
1234 (* Test various case combinations for GMT timezone *)
1235 let test_cases =
1236 [
1237 ("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT", "uppercase GMT");
1238 ("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 gmt", "lowercase gmt");
1239 ("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 Gmt", "mixed case Gmt");
1240 ("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GmT", "weird case GmT");
1241 ]
1242 in
1243
1244 List.iter
1245 (fun (header, description) ->
1246 let cookie_opt =
1247 of_set_cookie_header
1248 ~now:(fun () ->
1249 Ptime.of_float_s (Eio.Time.now clock)
1250 |> Option.value ~default:Ptime.epoch)
1251 ~domain:"example.com" ~path:"/" header
1252 in
1253 Alcotest.(check bool)
1254 (description ^ " parsed") true
1255 (Result.is_ok cookie_opt);
1256
1257 let cookie = Result.get_ok cookie_opt in
1258 Alcotest.(check bool)
1259 (description ^ " has expiry")
1260 true
1261 (Option.is_some (Cookeio.expires cookie));
1262
1263 (* Verify the date was parsed correctly regardless of GMT case *)
1264 let expires = Option.get (Cookeio.expires cookie) in
1265 match expires with
1266 | `DateTime ptime ->
1267 let year, month, day = Ptime.to_date ptime in
1268 Alcotest.(check int) (description ^ " year correct") 2015 year;
1269 Alcotest.(check int)
1270 (description ^ " month correct (October=10)")
1271 10 month;
1272 Alcotest.(check int) (description ^ " day correct") 21 day
1273 | `Session -> Alcotest.fail (description ^ " should not be session cookie"))
1274 test_cases
1275
1276(** {1 Delta Tracking Tests} *)
1277
1278let test_add_original_not_in_delta () =
1279 Eio_mock.Backend.run @@ fun () ->
1280 let clock = Eio_mock.Clock.make () in
1281 Eio_mock.Clock.set_time clock 1000.0;
1282
1283 let jar = create () in
1284 let cookie =
1285 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1286 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1287 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1288 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1289 ()
1290 in
1291 add_original jar cookie;
1292
1293 (* Delta should be empty *)
1294 let delta = delta jar in
1295 Alcotest.(check int) "delta is empty" 0 (List.length delta);
1296
1297 (* But the cookie should be in the jar *)
1298 Alcotest.(check int) "jar count is 1" 1 (count jar)
1299
1300let test_add_cookie_appears_in_delta () =
1301 Eio_mock.Backend.run @@ fun () ->
1302 let clock = Eio_mock.Clock.make () in
1303 Eio_mock.Clock.set_time clock 1000.0;
1304
1305 let jar = create () in
1306 let cookie =
1307 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1308 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1309 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1310 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1311 ()
1312 in
1313 add_cookie jar cookie;
1314
1315 (* Delta should contain the cookie *)
1316 let delta = delta jar in
1317 Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
1318 let delta_cookie = List.hd delta in
1319 Alcotest.(check string) "delta cookie name" "test" (Cookeio.name delta_cookie);
1320 Alcotest.(check string)
1321 "delta cookie value" "value"
1322 (Cookeio.value delta_cookie)
1323
1324let test_remove_original_creates_removal_cookie () =
1325 Eio_mock.Backend.run @@ fun () ->
1326 let clock = Eio_mock.Clock.make () in
1327 Eio_mock.Clock.set_time clock 1000.0;
1328
1329 let jar = create () in
1330 let cookie =
1331 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1332 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1333 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1334 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1335 ()
1336 in
1337 add_original jar cookie;
1338
1339 (* Remove the cookie *)
1340 remove jar ~clock cookie;
1341
1342 (* Delta should contain a removal cookie *)
1343 let delta = delta jar in
1344 Alcotest.(check int) "delta has 1 removal cookie" 1 (List.length delta);
1345 let removal_cookie = List.hd delta in
1346 Alcotest.(check string)
1347 "removal cookie name" "test"
1348 (Cookeio.name removal_cookie);
1349 Alcotest.(check string)
1350 "removal cookie has empty value" ""
1351 (Cookeio.value removal_cookie);
1352
1353 (* Check Max-Age is 0 *)
1354 match Cookeio.max_age removal_cookie with
1355 | Some span ->
1356 Alcotest.(check (option int))
1357 "removal cookie Max-Age is 0" (Some 0) (Ptime.Span.to_int_s span)
1358 | None -> Alcotest.fail "removal cookie should have Max-Age"
1359
1360let test_remove_delta_cookie_removes_it () =
1361 Eio_mock.Backend.run @@ fun () ->
1362 let clock = Eio_mock.Clock.make () in
1363 Eio_mock.Clock.set_time clock 1000.0;
1364
1365 let jar = create () in
1366 let cookie =
1367 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1368 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1369 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1370 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1371 ()
1372 in
1373 add_cookie jar cookie;
1374
1375 (* Remove the cookie *)
1376 remove jar ~clock cookie;
1377
1378 (* Delta should be empty *)
1379 let delta = delta jar in
1380 Alcotest.(check int)
1381 "delta is empty after removing delta cookie" 0 (List.length delta)
1382
1383let test_get_cookies_combines_original_and_delta () =
1384 Eio_mock.Backend.run @@ fun () ->
1385 let clock = Eio_mock.Clock.make () in
1386 Eio_mock.Clock.set_time clock 1000.0;
1387
1388 let jar = create () in
1389
1390 (* Add an original cookie *)
1391 let original =
1392 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"original"
1393 ~value:"orig_val" ~secure:false ~http_only:false ?expires:None
1394 ?same_site:None ?max_age:None
1395 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1396 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1397 ()
1398 in
1399 add_original jar original;
1400
1401 (* Add a delta cookie *)
1402 let delta_cookie =
1403 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"delta"
1404 ~value:"delta_val" ~secure:false ~http_only:false ?expires:None
1405 ?same_site:None ?max_age:None
1406 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1407 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1408 ()
1409 in
1410 add_cookie jar delta_cookie;
1411
1412 (* Get cookies should return both *)
1413 let cookies =
1414 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
1415 in
1416 Alcotest.(check int) "both cookies returned" 2 (List.length cookies);
1417
1418 let names = List.map Cookeio.name cookies |> List.sort String.compare in
1419 Alcotest.(check (list string)) "cookie names" [ "delta"; "original" ] names
1420
1421let test_get_cookies_delta_takes_precedence () =
1422 Eio_mock.Backend.run @@ fun () ->
1423 let clock = Eio_mock.Clock.make () in
1424 Eio_mock.Clock.set_time clock 1000.0;
1425
1426 let jar = create () in
1427
1428 (* Add an original cookie *)
1429 let original =
1430 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"orig_val"
1431 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1432 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1433 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1434 ()
1435 in
1436 add_original jar original;
1437
1438 (* Add a delta cookie with the same name/domain/path *)
1439 let delta_cookie =
1440 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"delta_val"
1441 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1442 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1443 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1444 ()
1445 in
1446 add_cookie jar delta_cookie;
1447
1448 (* Get cookies should return only the delta cookie *)
1449 let cookies =
1450 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
1451 in
1452 Alcotest.(check int) "only one cookie returned" 1 (List.length cookies);
1453 let cookie = List.hd cookies in
1454 Alcotest.(check string)
1455 "delta cookie value" "delta_val" (Cookeio.value cookie)
1456
1457let test_get_cookies_excludes_removal_cookies () =
1458 Eio_mock.Backend.run @@ fun () ->
1459 let clock = Eio_mock.Clock.make () in
1460 Eio_mock.Clock.set_time clock 1000.0;
1461
1462 let jar = create () in
1463
1464 (* Add an original cookie *)
1465 let original =
1466 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1467 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1468 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1469 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1470 ()
1471 in
1472 add_original jar original;
1473
1474 (* Remove it *)
1475 remove jar ~clock original;
1476
1477 (* Get cookies should return nothing *)
1478 let cookies =
1479 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
1480 in
1481 Alcotest.(check int) "no cookies returned" 0 (List.length cookies);
1482
1483 (* But delta should have the removal cookie *)
1484 let delta = delta jar in
1485 Alcotest.(check int) "delta has removal cookie" 1 (List.length delta)
1486
1487let test_delta_returns_only_changed_cookies () =
1488 Eio_mock.Backend.run @@ fun () ->
1489 let clock = Eio_mock.Clock.make () in
1490 Eio_mock.Clock.set_time clock 1000.0;
1491
1492 let jar = create () in
1493
1494 (* Add original cookies *)
1495 let original1 =
1496 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig1" ~value:"val1"
1497 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1498 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1499 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1500 ()
1501 in
1502 add_original jar original1;
1503
1504 let original2 =
1505 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig2" ~value:"val2"
1506 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1507 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1508 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1509 ()
1510 in
1511 add_original jar original2;
1512
1513 (* Add a new delta cookie *)
1514 let new_cookie =
1515 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"new_val"
1516 ~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
1517 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1518 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1519 ()
1520 in
1521 add_cookie jar new_cookie;
1522
1523 (* Delta should only contain the new cookie *)
1524 let delta = delta jar in
1525 Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
1526 let delta_cookie = List.hd delta in
1527 Alcotest.(check string) "delta cookie name" "new" (Cookeio.name delta_cookie)
1528
1529let test_removal_cookie_format () =
1530 Eio_mock.Backend.run @@ fun () ->
1531 let clock = Eio_mock.Clock.make () in
1532 Eio_mock.Clock.set_time clock 1000.0;
1533
1534 let jar = create () in
1535 let cookie =
1536 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
1537 ~secure:true ~http_only:true ?expires:None ~same_site:`Strict
1538 ?max_age:None
1539 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
1540 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
1541 ()
1542 in
1543 add_original jar cookie;
1544
1545 (* Remove the cookie *)
1546 remove jar ~clock cookie;
1547
1548 (* Get the removal cookie *)
1549 let delta = delta jar in
1550 let removal = List.hd delta in
1551
1552 (* Check all properties *)
1553 Alcotest.(check string)
1554 "removal cookie has empty value" "" (Cookeio.value removal);
1555 Alcotest.(check (option int))
1556 "removal cookie Max-Age is 0" (Some 0)
1557 (Option.bind (Cookeio.max_age removal) Ptime.Span.to_int_s);
1558
1559 (* Check expires is in the past *)
1560 let now = Ptime.of_float_s 1000.0 |> Option.get in
1561 match Cookeio.expires removal with
1562 | Some (`DateTime exp) ->
1563 Alcotest.(check bool)
1564 "expires is in the past" true
1565 (Ptime.compare exp now < 0)
1566 | _ -> Alcotest.fail "removal cookie should have DateTime expires"
1567
1568(* ============================================================================ *)
1569(* Priority 2 Tests *)
1570(* ============================================================================ *)
1571
1572(* Priority 2.1: Partitioned Cookies *)
1573
1574let test_partitioned_parsing env =
1575 let clock = Eio.Stdenv.clock env in
1576
1577 match
1578 of_set_cookie_header
1579 ~now:(fun () ->
1580 Ptime.of_float_s (Eio.Time.now clock)
1581 |> Option.value ~default:Ptime.epoch)
1582 ~domain:"widget.com" ~path:"/" "id=123; Partitioned; Secure"
1583 with
1584 | Ok c ->
1585 Alcotest.(check bool) "partitioned flag" true (partitioned c);
1586 Alcotest.(check bool) "secure flag" true (secure c)
1587 | Error msg -> Alcotest.fail ("Should parse valid Partitioned cookie: " ^ msg)
1588
1589let test_partitioned_serialization env =
1590 let clock = Eio.Stdenv.clock env in
1591 let now =
1592 Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
1593 in
1594
1595 let cookie =
1596 make ~domain:"widget.com" ~path:"/" ~name:"id" ~value:"123" ~secure:true
1597 ~partitioned:true ~creation_time:now ~last_access:now ()
1598 in
1599
1600 let header = make_set_cookie_header cookie in
1601 let contains_substring s sub =
1602 try
1603 let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1604 true
1605 with Not_found -> false
1606 in
1607 let has_partitioned = contains_substring header "Partitioned" in
1608 let has_secure = contains_substring header "Secure" in
1609 Alcotest.(check bool) "contains Partitioned" true has_partitioned;
1610 Alcotest.(check bool) "contains Secure" true has_secure
1611
1612let test_partitioned_requires_secure env =
1613 let clock = Eio.Stdenv.clock env in
1614
1615 (* Partitioned without Secure should be rejected *)
1616 match
1617 of_set_cookie_header
1618 ~now:(fun () ->
1619 Ptime.of_float_s (Eio.Time.now clock)
1620 |> Option.value ~default:Ptime.epoch)
1621 ~domain:"widget.com" ~path:"/" "id=123; Partitioned"
1622 with
1623 | Error _ -> () (* Expected *)
1624 | Ok _ -> Alcotest.fail "Should reject Partitioned without Secure"
1625
1626(* Priority 2.2: Expiration Variants *)
1627
1628let test_expiration_variants env =
1629 let clock = Eio.Stdenv.clock env in
1630 let now =
1631 Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
1632 in
1633 let make_base ~name ?expires () =
1634 make ~domain:"ex.com" ~path:"/" ~name ~value:"v" ?expires ~creation_time:now
1635 ~last_access:now ()
1636 in
1637
1638 (* No expiration *)
1639 let c1 = make_base ~name:"no_expiry" () in
1640 Alcotest.(check (option expiration_testable))
1641 "no expiration" None (expires c1);
1642
1643 (* Session cookie *)
1644 let c2 = make_base ~name:"session" ~expires:`Session () in
1645 Alcotest.(check (option expiration_testable))
1646 "session cookie" (Some `Session) (expires c2);
1647
1648 (* Explicit expiration *)
1649 let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
1650 let c3 = make_base ~name:"persistent" ~expires:(`DateTime future) () in
1651 match expires c3 with
1652 | Some (`DateTime t) when Ptime.equal t future -> ()
1653 | _ -> Alcotest.fail "Expected DateTime expiration"
1654
1655let test_parse_session_expiration env =
1656 let clock = Eio.Stdenv.clock env in
1657
1658 (* Expires=0 should parse as Session *)
1659 match
1660 of_set_cookie_header
1661 ~now:(fun () ->
1662 Ptime.of_float_s (Eio.Time.now clock)
1663 |> Option.value ~default:Ptime.epoch)
1664 ~domain:"ex.com" ~path:"/" "id=123; Expires=0"
1665 with
1666 | Ok c ->
1667 Alcotest.(check (option expiration_testable))
1668 "expires=0 is session" (Some `Session) (expires c)
1669 | Error msg -> Alcotest.fail ("Should parse Expires=0: " ^ msg)
1670
1671let test_serialize_expiration_variants env =
1672 let clock = Eio.Stdenv.clock env in
1673 let now =
1674 Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
1675 in
1676 let contains_substring s sub =
1677 try
1678 let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1679 true
1680 with Not_found -> false
1681 in
1682
1683 (* Session cookie serialization *)
1684 let c1 =
1685 make ~domain:"ex.com" ~path:"/" ~name:"s" ~value:"v" ~expires:`Session
1686 ~creation_time:now ~last_access:now ()
1687 in
1688 let h1 = make_set_cookie_header c1 in
1689 let has_expires = contains_substring h1 "Expires=" in
1690 Alcotest.(check bool) "session has Expires" true has_expires;
1691
1692 (* DateTime serialization *)
1693 let future = Ptime.add_span now (Ptime.Span.of_int_s 3600) |> Option.get in
1694 let c2 =
1695 make ~domain:"ex.com" ~path:"/" ~name:"p" ~value:"v"
1696 ~expires:(`DateTime future) ~creation_time:now ~last_access:now ()
1697 in
1698 let h2 = make_set_cookie_header c2 in
1699 let has_expires2 = contains_substring h2 "Expires=" in
1700 Alcotest.(check bool) "datetime has Expires" true has_expires2
1701
1702(* Priority 2.3: Value Trimming *)
1703
1704let test_quoted_cookie_values env =
1705 let clock = Eio.Stdenv.clock env in
1706 (* Test valid RFC 6265 cookie values:
1707 cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
1708 Valid cases have either no quotes or properly paired DQUOTE wrapper *)
1709 let valid_cases =
1710 [
1711 ("name=value", "value", "value"); (* No quotes *)
1712 ("name=\"value\"", "\"value\"", "value"); (* Properly quoted *)
1713 ("name=\"\"", "\"\"", ""); (* Empty quoted value *)
1714 ]
1715 in
1716
1717 List.iter
1718 (fun (input, expected_raw, expected_trimmed) ->
1719 match
1720 of_set_cookie_header
1721 ~now:(fun () ->
1722 Ptime.of_float_s (Eio.Time.now clock)
1723 |> Option.value ~default:Ptime.epoch)
1724 ~domain:"ex.com" ~path:"/" input
1725 with
1726 | Ok c ->
1727 Alcotest.(check string)
1728 (Printf.sprintf "raw value for %s" input)
1729 expected_raw (value c);
1730 Alcotest.(check string)
1731 (Printf.sprintf "trimmed value for %s" input)
1732 expected_trimmed (value_trimmed c)
1733 | Error msg -> Alcotest.fail ("Parse failed: " ^ input ^ ": " ^ msg))
1734 valid_cases;
1735
1736 (* Test invalid RFC 6265 cookie values are rejected *)
1737 let invalid_cases =
1738 [
1739 "name=\"partial"; (* Opening quote without closing *)
1740 "name=\"val\"\""; (* Embedded quote *)
1741 "name=val\""; (* Trailing quote without opening *)
1742 ]
1743 in
1744
1745 List.iter
1746 (fun input ->
1747 match
1748 of_set_cookie_header
1749 ~now:(fun () ->
1750 Ptime.of_float_s (Eio.Time.now clock)
1751 |> Option.value ~default:Ptime.epoch)
1752 ~domain:"ex.com" ~path:"/" input
1753 with
1754 | Error _ -> () (* Expected - invalid values are rejected *)
1755 | Ok _ ->
1756 Alcotest.fail
1757 (Printf.sprintf "Should reject invalid value: %s" input))
1758 invalid_cases
1759
1760let test_trimmed_value_not_used_for_equality env =
1761 let clock = Eio.Stdenv.clock env in
1762
1763 match
1764 of_set_cookie_header
1765 ~now:(fun () ->
1766 Ptime.of_float_s (Eio.Time.now clock)
1767 |> Option.value ~default:Ptime.epoch)
1768 ~domain:"ex.com" ~path:"/" "name=\"value\""
1769 with
1770 | Ok c1 -> begin
1771 match
1772 of_set_cookie_header
1773 ~now:(fun () ->
1774 Ptime.of_float_s (Eio.Time.now clock)
1775 |> Option.value ~default:Ptime.epoch)
1776 ~domain:"ex.com" ~path:"/" "name=value"
1777 with
1778 | Ok c2 ->
1779 (* Different raw values *)
1780 Alcotest.(check bool)
1781 "different raw values" false
1782 (value c1 = value c2);
1783 (* Same trimmed values *)
1784 Alcotest.(check string)
1785 "same trimmed values" (value_trimmed c1) (value_trimmed c2)
1786 | Error msg -> Alcotest.fail ("Parse failed for unquoted: " ^ msg)
1787 end
1788 | Error msg -> Alcotest.fail ("Parse failed for quoted: " ^ msg)
1789
1790(* Priority 2.4: Cookie Header Parsing *)
1791
1792let test_cookie_header_parsing_basic env =
1793 let clock = Eio.Stdenv.clock env in
1794 let result =
1795 of_cookie_header
1796 ~now:(fun () ->
1797 Ptime.of_float_s (Eio.Time.now clock)
1798 |> Option.value ~default:Ptime.epoch)
1799 ~domain:"ex.com" ~path:"/" "session=abc123; theme=dark; lang=en"
1800 in
1801
1802 match result with
1803 | Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
1804 | Ok cookies ->
1805 Alcotest.(check int) "parsed 3 cookies" 3 (List.length cookies);
1806
1807 let find name_val = List.find (fun c -> name c = name_val) cookies in
1808 Alcotest.(check string) "session value" "abc123" (value (find "session"));
1809 Alcotest.(check string) "theme value" "dark" (value (find "theme"));
1810 Alcotest.(check string) "lang value" "en" (value (find "lang"))
1811
1812let test_cookie_header_defaults env =
1813 let clock = Eio.Stdenv.clock env in
1814
1815 match
1816 of_cookie_header
1817 ~now:(fun () ->
1818 Ptime.of_float_s (Eio.Time.now clock)
1819 |> Option.value ~default:Ptime.epoch)
1820 ~domain:"example.com" ~path:"/app" "session=xyz"
1821 with
1822 | Ok [ c ] ->
1823 (* Domain and path from request context *)
1824 Alcotest.(check string) "domain from context" "example.com" (domain c);
1825 Alcotest.(check string) "path from context" "/app" (path c);
1826
1827 (* Security flags default to false *)
1828 Alcotest.(check bool) "secure default" false (secure c);
1829 Alcotest.(check bool) "http_only default" false (http_only c);
1830 Alcotest.(check bool) "partitioned default" false (partitioned c);
1831
1832 (* Optional attributes default to None *)
1833 Alcotest.(check (option expiration_testable))
1834 "no expiration" None (expires c);
1835 Alcotest.(check (option span_testable)) "no max_age" None (max_age c);
1836 Alcotest.(check (option same_site_testable))
1837 "no same_site" None (same_site c)
1838 | Ok _ -> Alcotest.fail "Should parse single cookie"
1839 | Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
1840
1841let test_cookie_header_edge_cases env =
1842 let clock = Eio.Stdenv.clock env in
1843
1844 let test input expected_count description =
1845 let result =
1846 of_cookie_header
1847 ~now:(fun () ->
1848 Ptime.of_float_s (Eio.Time.now clock)
1849 |> Option.value ~default:Ptime.epoch)
1850 ~domain:"ex.com" ~path:"/" input
1851 in
1852 match result with
1853 | Ok cookies ->
1854 Alcotest.(check int) description expected_count (List.length cookies)
1855 | Error msg ->
1856 Alcotest.fail (description ^ " failed: " ^ msg)
1857 in
1858
1859 test "" 0 "empty string";
1860 test ";;" 0 "only separators";
1861 test "a=1;;b=2" 2 "double separator";
1862 test " a=1 ; b=2 " 2 "excess whitespace";
1863 test " " 0 "only whitespace"
1864
1865let test_cookie_header_with_errors env =
1866 let clock = Eio.Stdenv.clock env in
1867
1868 (* Invalid cookie (empty name) should cause entire parse to fail *)
1869 let result =
1870 of_cookie_header
1871 ~now:(fun () ->
1872 Ptime.of_float_s (Eio.Time.now clock)
1873 |> Option.value ~default:Ptime.epoch)
1874 ~domain:"ex.com" ~path:"/" "valid=1;=noname;valid2=2"
1875 in
1876
1877 (* Error should have descriptive message about the invalid cookie *)
1878 let contains_substring s sub =
1879 try
1880 let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1881 true
1882 with Not_found -> false
1883 in
1884 match result with
1885 | Error msg ->
1886 let has_name = contains_substring msg "name" in
1887 let has_empty = contains_substring msg "empty" in
1888 Alcotest.(check bool)
1889 "error mentions name or empty" true (has_name || has_empty)
1890 | Ok _ -> Alcotest.fail "Expected error for empty cookie name"
1891
1892(* Max-Age and Expires Interaction *)
1893
1894let test_max_age_and_expires_both_present env =
1895 let clock = Eio.Stdenv.clock env in
1896 let now =
1897 Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
1898 in
1899 let future = Ptime.add_span now (Ptime.Span.of_int_s 7200) |> Option.get in
1900
1901 (* Create cookie with both *)
1902 let cookie =
1903 make ~domain:"ex.com" ~path:"/" ~name:"dual" ~value:"val"
1904 ~max_age:(Ptime.Span.of_int_s 3600) ~expires:(`DateTime future)
1905 ~creation_time:now ~last_access:now ()
1906 in
1907
1908 (* Both should be present *)
1909 begin match max_age cookie with
1910 | Some span -> begin
1911 match Ptime.Span.to_int_s span with
1912 | Some s ->
1913 Alcotest.(check int64) "max_age present" 3600L (Int64.of_int s)
1914 | None -> Alcotest.fail "max_age span could not be converted to int"
1915 end
1916 | None -> Alcotest.fail "max_age should be present"
1917 end;
1918
1919 begin match expires cookie with
1920 | Some (`DateTime t) when Ptime.equal t future -> ()
1921 | _ -> Alcotest.fail "expires should be present"
1922 end;
1923
1924 (* Both should appear in serialization *)
1925 let header = make_set_cookie_header cookie in
1926 let contains_substring s sub =
1927 try
1928 let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1929 true
1930 with Not_found -> false
1931 in
1932 let has_max_age = contains_substring header "Max-Age=3600" in
1933 let has_expires = contains_substring header "Expires=" in
1934 Alcotest.(check bool) "contains Max-Age" true has_max_age;
1935 Alcotest.(check bool) "contains Expires" true has_expires
1936
1937let test_parse_max_age_and_expires env =
1938 let clock = Eio.Stdenv.clock env in
1939
1940 (* Parse Set-Cookie with both attributes *)
1941 match
1942 of_set_cookie_header
1943 ~now:(fun () ->
1944 Ptime.of_float_s (Eio.Time.now clock)
1945 |> Option.value ~default:Ptime.epoch)
1946 ~domain:"ex.com" ~path:"/"
1947 "id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT"
1948 with
1949 | Ok c ->
1950 (* Both should be stored *)
1951 begin match max_age c with
1952 | Some span -> begin
1953 match Ptime.Span.to_int_s span with
1954 | Some s ->
1955 Alcotest.(check int64) "max_age parsed" 3600L (Int64.of_int s)
1956 | None -> Alcotest.fail "max_age span could not be converted to int"
1957 end
1958 | None -> Alcotest.fail "max_age should be parsed"
1959 end;
1960
1961 begin match expires c with
1962 | Some (`DateTime _) -> ()
1963 | _ -> Alcotest.fail "expires should be parsed"
1964 end
1965 | Error msg -> Alcotest.fail ("Should parse cookie with both attributes: " ^ msg)
1966
1967(* ============================================================================ *)
1968(* Host-Only Flag Tests (RFC 6265 Section 5.3) *)
1969(* ============================================================================ *)
1970
1971let test_host_only_without_domain_attribute () =
1972 Eio_mock.Backend.run @@ fun () ->
1973 let clock = Eio_mock.Clock.make () in
1974 Eio_mock.Clock.set_time clock 1000.0;
1975
1976 (* Cookie without Domain attribute should have host_only=true *)
1977 let header = "session=abc123; Secure; HttpOnly" in
1978 let cookie_opt =
1979 of_set_cookie_header
1980 ~now:(fun () ->
1981 Ptime.of_float_s (Eio.Time.now clock)
1982 |> Option.value ~default:Ptime.epoch)
1983 ~domain:"example.com" ~path:"/" header
1984 in
1985 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
1986 let cookie = Result.get_ok cookie_opt in
1987 Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
1988 Alcotest.(check string) "domain is request host" "example.com" (Cookeio.domain cookie)
1989
1990let test_host_only_with_domain_attribute () =
1991 Eio_mock.Backend.run @@ fun () ->
1992 let clock = Eio_mock.Clock.make () in
1993 Eio_mock.Clock.set_time clock 1000.0;
1994
1995 (* Cookie with Domain attribute should have host_only=false *)
1996 let header = "session=abc123; Domain=example.com; Secure" in
1997 let cookie_opt =
1998 of_set_cookie_header
1999 ~now:(fun () ->
2000 Ptime.of_float_s (Eio.Time.now clock)
2001 |> Option.value ~default:Ptime.epoch)
2002 ~domain:"example.com" ~path:"/" header
2003 in
2004 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
2005 let cookie = Result.get_ok cookie_opt in
2006 Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
2007 Alcotest.(check string) "domain is attribute value" "example.com" (Cookeio.domain cookie)
2008
2009let test_host_only_with_dotted_domain_attribute () =
2010 Eio_mock.Backend.run @@ fun () ->
2011 let clock = Eio_mock.Clock.make () in
2012 Eio_mock.Clock.set_time clock 1000.0;
2013
2014 (* Cookie with .domain should have host_only=false and normalized domain *)
2015 let header = "session=abc123; Domain=.example.com" in
2016 let cookie_opt =
2017 of_set_cookie_header
2018 ~now:(fun () ->
2019 Ptime.of_float_s (Eio.Time.now clock)
2020 |> Option.value ~default:Ptime.epoch)
2021 ~domain:"example.com" ~path:"/" header
2022 in
2023 Alcotest.(check bool) "cookie parsed" true (Result.is_ok cookie_opt);
2024 let cookie = Result.get_ok cookie_opt in
2025 Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
2026 Alcotest.(check string) "domain normalized" "example.com" (Cookeio.domain cookie)
2027
2028let test_host_only_domain_matching () =
2029 Eio_mock.Backend.run @@ fun () ->
2030 let clock = Eio_mock.Clock.make () in
2031 Eio_mock.Clock.set_time clock 1000.0;
2032
2033 let jar = create () in
2034
2035 (* Add a host-only cookie (no Domain attribute) *)
2036 let host_only_cookie =
2037 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"host_only" ~value:"val1"
2038 ~secure:false ~http_only:false ~host_only:true
2039 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2040 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2041 in
2042 add_cookie jar host_only_cookie;
2043
2044 (* Add a domain cookie (with Domain attribute) *)
2045 let domain_cookie =
2046 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"val2"
2047 ~secure:false ~http_only:false ~host_only:false
2048 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2049 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2050 in
2051 add_cookie jar domain_cookie;
2052
2053 (* Both cookies should match exact domain *)
2054 let cookies_exact =
2055 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2056 in
2057 Alcotest.(check int) "both match exact domain" 2 (List.length cookies_exact);
2058
2059 (* Only domain cookie should match subdomain *)
2060 let cookies_sub =
2061 get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
2062 in
2063 Alcotest.(check int) "only domain cookie matches subdomain" 1 (List.length cookies_sub);
2064 let sub_cookie = List.hd cookies_sub in
2065 Alcotest.(check string) "subdomain match is domain cookie" "domain" (Cookeio.name sub_cookie)
2066
2067let test_host_only_cookie_header_parsing () =
2068 Eio_mock.Backend.run @@ fun () ->
2069 let clock = Eio_mock.Clock.make () in
2070 Eio_mock.Clock.set_time clock 1000.0;
2071
2072 (* Cookies from Cookie header should have host_only=true *)
2073 let result =
2074 of_cookie_header
2075 ~now:(fun () ->
2076 Ptime.of_float_s (Eio.Time.now clock)
2077 |> Option.value ~default:Ptime.epoch)
2078 ~domain:"example.com" ~path:"/" "session=abc; theme=dark"
2079 in
2080 match result with
2081 | Error msg -> Alcotest.fail ("Parse failed: " ^ msg)
2082 | Ok cookies ->
2083 Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies);
2084 List.iter (fun c ->
2085 Alcotest.(check bool)
2086 ("host_only is true for " ^ Cookeio.name c)
2087 true (Cookeio.host_only c)
2088 ) cookies
2089
2090let test_host_only_mozilla_format_round_trip () =
2091 Eio_mock.Backend.run @@ fun () ->
2092 let clock = Eio_mock.Clock.make () in
2093 Eio_mock.Clock.set_time clock 1000.0;
2094
2095 let jar = create () in
2096
2097 (* Add host-only cookie *)
2098 let host_only =
2099 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostonly" ~value:"v1"
2100 ~secure:false ~http_only:false ~host_only:true
2101 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2102 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2103 in
2104 add_cookie jar host_only;
2105
2106 (* Add domain cookie *)
2107 let domain_cookie =
2108 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"v2"
2109 ~secure:false ~http_only:false ~host_only:false
2110 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2111 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2112 in
2113 add_cookie jar domain_cookie;
2114
2115 (* Round trip through Mozilla format *)
2116 let mozilla = to_mozilla_format jar in
2117 let jar2 = from_mozilla_format ~clock mozilla in
2118 let cookies = get_all_cookies jar2 in
2119
2120 Alcotest.(check int) "2 cookies after round trip" 2 (List.length cookies);
2121
2122 let find name_val = List.find (fun c -> Cookeio.name c = name_val) cookies in
2123 Alcotest.(check bool) "hostonly preserved" true (Cookeio.host_only (find "hostonly"));
2124 Alcotest.(check bool) "domain preserved" false (Cookeio.host_only (find "domain"))
2125
2126(* ============================================================================ *)
2127(* Path Matching Tests (RFC 6265 Section 5.1.4) *)
2128(* ============================================================================ *)
2129
2130let test_path_matching_identical () =
2131 Eio_mock.Backend.run @@ fun () ->
2132 let clock = Eio_mock.Clock.make () in
2133 Eio_mock.Clock.set_time clock 1000.0;
2134
2135 let jar = create () in
2136 let cookie =
2137 Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
2138 ~secure:false ~http_only:false
2139 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2140 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2141 in
2142 add_cookie jar cookie;
2143
2144 (* Identical path should match *)
2145 let cookies =
2146 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
2147 in
2148 Alcotest.(check int) "identical path matches" 1 (List.length cookies)
2149
2150let test_path_matching_with_trailing_slash () =
2151 Eio_mock.Backend.run @@ fun () ->
2152 let clock = Eio_mock.Clock.make () in
2153 Eio_mock.Clock.set_time clock 1000.0;
2154
2155 let jar = create () in
2156 let cookie =
2157 Cookeio.make ~domain:"example.com" ~path:"/foo/" ~name:"test" ~value:"val"
2158 ~secure:false ~http_only:false
2159 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2160 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2161 in
2162 add_cookie jar cookie;
2163
2164 (* Cookie path /foo/ should match /foo/bar *)
2165 let cookies =
2166 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
2167 in
2168 Alcotest.(check int) "/foo/ matches /foo/bar" 1 (List.length cookies);
2169
2170 (* Cookie path /foo/ should match /foo/ *)
2171 let cookies2 =
2172 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
2173 in
2174 Alcotest.(check int) "/foo/ matches /foo/" 1 (List.length cookies2)
2175
2176let test_path_matching_prefix_with_slash () =
2177 Eio_mock.Backend.run @@ fun () ->
2178 let clock = Eio_mock.Clock.make () in
2179 Eio_mock.Clock.set_time clock 1000.0;
2180
2181 let jar = create () in
2182 let cookie =
2183 Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
2184 ~secure:false ~http_only:false
2185 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2186 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2187 in
2188 add_cookie jar cookie;
2189
2190 (* Cookie path /foo should match /foo/bar (next char is /) *)
2191 let cookies =
2192 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
2193 in
2194 Alcotest.(check int) "/foo matches /foo/bar" 1 (List.length cookies);
2195
2196 (* Cookie path /foo should match /foo/ *)
2197 let cookies2 =
2198 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
2199 in
2200 Alcotest.(check int) "/foo matches /foo/" 1 (List.length cookies2)
2201
2202let test_path_matching_no_false_prefix () =
2203 Eio_mock.Backend.run @@ fun () ->
2204 let clock = Eio_mock.Clock.make () in
2205 Eio_mock.Clock.set_time clock 1000.0;
2206
2207 let jar = create () in
2208 let cookie =
2209 Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
2210 ~secure:false ~http_only:false
2211 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2212 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2213 in
2214 add_cookie jar cookie;
2215
2216 (* Cookie path /foo should NOT match /foobar (no / separator) *)
2217 let cookies =
2218 get_cookies jar ~clock ~domain:"example.com" ~path:"/foobar" ~is_secure:false
2219 in
2220 Alcotest.(check int) "/foo does NOT match /foobar" 0 (List.length cookies);
2221
2222 (* Cookie path /foo should NOT match /foob *)
2223 let cookies2 =
2224 get_cookies jar ~clock ~domain:"example.com" ~path:"/foob" ~is_secure:false
2225 in
2226 Alcotest.(check int) "/foo does NOT match /foob" 0 (List.length cookies2)
2227
2228let test_path_matching_root () =
2229 Eio_mock.Backend.run @@ fun () ->
2230 let clock = Eio_mock.Clock.make () in
2231 Eio_mock.Clock.set_time clock 1000.0;
2232
2233 let jar = create () in
2234 let cookie =
2235 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"val"
2236 ~secure:false ~http_only:false
2237 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2238 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2239 in
2240 add_cookie jar cookie;
2241
2242 (* Root path should match everything *)
2243 let cookies1 =
2244 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2245 in
2246 Alcotest.(check int) "/ matches /" 1 (List.length cookies1);
2247
2248 let cookies2 =
2249 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
2250 in
2251 Alcotest.(check int) "/ matches /foo" 1 (List.length cookies2);
2252
2253 let cookies3 =
2254 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
2255 in
2256 Alcotest.(check int) "/ matches /foo/bar/baz" 1 (List.length cookies3)
2257
2258let test_path_matching_no_match () =
2259 Eio_mock.Backend.run @@ fun () ->
2260 let clock = Eio_mock.Clock.make () in
2261 Eio_mock.Clock.set_time clock 1000.0;
2262
2263 let jar = create () in
2264 let cookie =
2265 Cookeio.make ~domain:"example.com" ~path:"/foo/bar" ~name:"test" ~value:"val"
2266 ~secure:false ~http_only:false
2267 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2268 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2269 in
2270 add_cookie jar cookie;
2271
2272 (* Cookie path /foo/bar should NOT match /foo *)
2273 let cookies =
2274 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
2275 in
2276 Alcotest.(check int) "/foo/bar does NOT match /foo" 0 (List.length cookies);
2277
2278 (* Cookie path /foo/bar should NOT match / *)
2279 let cookies2 =
2280 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2281 in
2282 Alcotest.(check int) "/foo/bar does NOT match /" 0 (List.length cookies2);
2283
2284 (* Cookie path /foo/bar should NOT match /baz *)
2285 let cookies3 =
2286 get_cookies jar ~clock ~domain:"example.com" ~path:"/baz" ~is_secure:false
2287 in
2288 Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
2289
2290(* ============================================================================ *)
2291(* Cookie Ordering Tests (RFC 6265 Section 5.4, Step 2) *)
2292(* ============================================================================ *)
2293
2294let test_cookie_ordering_by_path_length () =
2295 Eio_mock.Backend.run @@ fun () ->
2296 let clock = Eio_mock.Clock.make () in
2297 Eio_mock.Clock.set_time clock 1000.0;
2298
2299 let jar = create () in
2300
2301 (* Add cookies with different path lengths, but same creation time *)
2302 let cookie_short =
2303 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"short" ~value:"v1"
2304 ~secure:false ~http_only:false
2305 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2306 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2307 in
2308 let cookie_medium =
2309 Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"medium" ~value:"v2"
2310 ~secure:false ~http_only:false
2311 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2312 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2313 in
2314 let cookie_long =
2315 Cookeio.make ~domain:"example.com" ~path:"/foo/bar" ~name:"long" ~value:"v3"
2316 ~secure:false ~http_only:false
2317 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2318 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2319 in
2320
2321 (* Add in random order *)
2322 add_cookie jar cookie_short;
2323 add_cookie jar cookie_long;
2324 add_cookie jar cookie_medium;
2325
2326 (* Get cookies for path /foo/bar/baz - all three should match *)
2327 let cookies =
2328 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
2329 in
2330
2331 Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
2332
2333 (* Verify order: longest path first *)
2334 let names = List.map Cookeio.name cookies in
2335 Alcotest.(check (list string))
2336 "cookies ordered by path length (longest first)"
2337 [ "long"; "medium"; "short" ]
2338 names
2339
2340let test_cookie_ordering_by_creation_time () =
2341 Eio_mock.Backend.run @@ fun () ->
2342 let clock = Eio_mock.Clock.make () in
2343 Eio_mock.Clock.set_time clock 2000.0;
2344
2345 let jar = create () in
2346
2347 (* Add cookies with same path but different creation times *)
2348 let cookie_new =
2349 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
2350 ~secure:false ~http_only:false
2351 ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
2352 ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
2353 in
2354 let cookie_old =
2355 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"old" ~value:"v2"
2356 ~secure:false ~http_only:false
2357 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2358 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2359 in
2360 let cookie_middle =
2361 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"middle" ~value:"v3"
2362 ~secure:false ~http_only:false
2363 ~creation_time:(Ptime.of_float_s 1200.0 |> Option.get)
2364 ~last_access:(Ptime.of_float_s 1200.0 |> Option.get) ()
2365 in
2366
2367 (* Add in random order *)
2368 add_cookie jar cookie_new;
2369 add_cookie jar cookie_old;
2370 add_cookie jar cookie_middle;
2371
2372 let cookies =
2373 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2374 in
2375
2376 Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
2377
2378 (* Verify order: earlier creation time first (for same path length) *)
2379 let names = List.map Cookeio.name cookies in
2380 Alcotest.(check (list string))
2381 "cookies ordered by creation time (earliest first)"
2382 [ "old"; "middle"; "new" ]
2383 names
2384
2385let test_cookie_ordering_combined () =
2386 Eio_mock.Backend.run @@ fun () ->
2387 let clock = Eio_mock.Clock.make () in
2388 Eio_mock.Clock.set_time clock 2000.0;
2389
2390 let jar = create () in
2391
2392 (* Mix of different paths and creation times *)
2393 let cookie_a =
2394 Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"a" ~value:"v1"
2395 ~secure:false ~http_only:false
2396 ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
2397 ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
2398 in
2399 let cookie_b =
2400 Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"b" ~value:"v2"
2401 ~secure:false ~http_only:false
2402 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2403 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2404 in
2405 let cookie_c =
2406 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"c" ~value:"v3"
2407 ~secure:false ~http_only:false
2408 ~creation_time:(Ptime.of_float_s 500.0 |> Option.get)
2409 ~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
2410 in
2411
2412 add_cookie jar cookie_a;
2413 add_cookie jar cookie_c;
2414 add_cookie jar cookie_b;
2415
2416 let cookies =
2417 get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
2418 in
2419
2420 Alcotest.(check int) "all 3 cookies match" 3 (List.length cookies);
2421
2422 (* /foo cookies (length 4) should come before / cookie (length 1)
2423 Within /foo, earlier creation time (b=1000) should come before (a=1500) *)
2424 let names = List.map Cookeio.name cookies in
2425 Alcotest.(check (list string))
2426 "cookies ordered by path length then creation time"
2427 [ "b"; "a"; "c" ]
2428 names
2429
2430(* ============================================================================ *)
2431(* Creation Time Preservation Tests (RFC 6265 Section 5.3, Step 11.3) *)
2432(* ============================================================================ *)
2433
2434let test_creation_time_preserved_on_update () =
2435 Eio_mock.Backend.run @@ fun () ->
2436 let clock = Eio_mock.Clock.make () in
2437 Eio_mock.Clock.set_time clock 1000.0;
2438
2439 let jar = create () in
2440
2441 (* Add initial cookie with creation_time=500 *)
2442 let original_creation = Ptime.of_float_s 500.0 |> Option.get in
2443 let cookie_v1 =
2444 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v1"
2445 ~secure:false ~http_only:false
2446 ~creation_time:original_creation
2447 ~last_access:(Ptime.of_float_s 500.0 |> Option.get) ()
2448 in
2449 add_cookie jar cookie_v1;
2450
2451 (* Update the cookie with a new value (creation_time=1000) *)
2452 Eio_mock.Clock.set_time clock 1500.0;
2453 let cookie_v2 =
2454 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"v2"
2455 ~secure:false ~http_only:false
2456 ~creation_time:(Ptime.of_float_s 1500.0 |> Option.get)
2457 ~last_access:(Ptime.of_float_s 1500.0 |> Option.get) ()
2458 in
2459 add_cookie jar cookie_v2;
2460
2461 (* Get the cookie and verify creation_time was preserved *)
2462 let cookies =
2463 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2464 in
2465 Alcotest.(check int) "still one cookie" 1 (List.length cookies);
2466
2467 let cookie = List.hd cookies in
2468 Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
2469
2470 (* Creation time should be preserved from original cookie *)
2471 let creation_float =
2472 Ptime.to_float_s (Cookeio.creation_time cookie)
2473 in
2474 Alcotest.(check (float 0.001))
2475 "creation_time preserved from original"
2476 500.0 creation_float
2477
2478let test_creation_time_preserved_add_original () =
2479 Eio_mock.Backend.run @@ fun () ->
2480 let clock = Eio_mock.Clock.make () in
2481 Eio_mock.Clock.set_time clock 1000.0;
2482
2483 let jar = create () in
2484
2485 (* Add initial original cookie *)
2486 let original_creation = Ptime.of_float_s 100.0 |> Option.get in
2487 let cookie_v1 =
2488 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v1"
2489 ~secure:false ~http_only:false
2490 ~creation_time:original_creation
2491 ~last_access:(Ptime.of_float_s 100.0 |> Option.get) ()
2492 in
2493 add_original jar cookie_v1;
2494
2495 (* Replace with new original cookie *)
2496 let cookie_v2 =
2497 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"v2"
2498 ~secure:false ~http_only:false
2499 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2500 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2501 in
2502 add_original jar cookie_v2;
2503
2504 let cookies = get_all_cookies jar in
2505 Alcotest.(check int) "still one cookie" 1 (List.length cookies);
2506
2507 let cookie = List.hd cookies in
2508 Alcotest.(check string) "value was updated" "v2" (Cookeio.value cookie);
2509
2510 (* Creation time should be preserved *)
2511 let creation_float =
2512 Ptime.to_float_s (Cookeio.creation_time cookie)
2513 in
2514 Alcotest.(check (float 0.001))
2515 "creation_time preserved in add_original"
2516 100.0 creation_float
2517
2518let test_creation_time_new_cookie () =
2519 Eio_mock.Backend.run @@ fun () ->
2520 let clock = Eio_mock.Clock.make () in
2521 Eio_mock.Clock.set_time clock 1000.0;
2522
2523 let jar = create () in
2524
2525 (* Add a new cookie (no existing cookie to preserve from) *)
2526 let cookie =
2527 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"v1"
2528 ~secure:false ~http_only:false
2529 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2530 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2531 in
2532 add_cookie jar cookie;
2533
2534 let cookies =
2535 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2536 in
2537 let cookie = List.hd cookies in
2538
2539 (* New cookie should keep its own creation time *)
2540 let creation_float =
2541 Ptime.to_float_s (Cookeio.creation_time cookie)
2542 in
2543 Alcotest.(check (float 0.001))
2544 "new cookie keeps its creation_time"
2545 1000.0 creation_float
2546
2547(* ============================================================================ *)
2548(* IP Address Domain Matching Tests (RFC 6265 Section 5.1.3) *)
2549(* ============================================================================ *)
2550
2551let test_ipv4_exact_match () =
2552 Eio_mock.Backend.run @@ fun () ->
2553 let clock = Eio_mock.Clock.make () in
2554 Eio_mock.Clock.set_time clock 1000.0;
2555
2556 let jar = create () in
2557 let cookie =
2558 Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val"
2559 ~secure:false ~http_only:false ~host_only:false
2560 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2561 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2562 in
2563 add_cookie jar cookie;
2564
2565 (* IPv4 cookie should match exact IP *)
2566 let cookies =
2567 get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
2568 in
2569 Alcotest.(check int) "IPv4 exact match" 1 (List.length cookies)
2570
2571let test_ipv4_no_suffix_match () =
2572 Eio_mock.Backend.run @@ fun () ->
2573 let clock = Eio_mock.Clock.make () in
2574 Eio_mock.Clock.set_time clock 1000.0;
2575
2576 let jar = create () in
2577 (* Cookie for 168.1.1 - this should NOT match requests to 192.168.1.1
2578 even though "192.168.1.1" ends with ".168.1.1" *)
2579 let cookie =
2580 Cookeio.make ~domain:"168.1.1" ~path:"/" ~name:"test" ~value:"val"
2581 ~secure:false ~http_only:false ~host_only:false
2582 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2583 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2584 in
2585 add_cookie jar cookie;
2586
2587 (* Should NOT match - IP addresses don't do suffix matching *)
2588 let cookies =
2589 get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
2590 in
2591 Alcotest.(check int) "IPv4 no suffix match" 0 (List.length cookies)
2592
2593let test_ipv4_different_ip () =
2594 Eio_mock.Backend.run @@ fun () ->
2595 let clock = Eio_mock.Clock.make () in
2596 Eio_mock.Clock.set_time clock 1000.0;
2597
2598 let jar = create () in
2599 let cookie =
2600 Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"test" ~value:"val"
2601 ~secure:false ~http_only:false ~host_only:false
2602 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2603 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2604 in
2605 add_cookie jar cookie;
2606
2607 (* Different IP should not match *)
2608 let cookies =
2609 get_cookies jar ~clock ~domain:"192.168.1.2" ~path:"/" ~is_secure:false
2610 in
2611 Alcotest.(check int) "different IPv4 no match" 0 (List.length cookies)
2612
2613let test_ipv6_exact_match () =
2614 Eio_mock.Backend.run @@ fun () ->
2615 let clock = Eio_mock.Clock.make () in
2616 Eio_mock.Clock.set_time clock 1000.0;
2617
2618 let jar = create () in
2619 let cookie =
2620 Cookeio.make ~domain:"::1" ~path:"/" ~name:"test" ~value:"val"
2621 ~secure:false ~http_only:false ~host_only:false
2622 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2623 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2624 in
2625 add_cookie jar cookie;
2626
2627 (* IPv6 loopback should match exactly *)
2628 let cookies =
2629 get_cookies jar ~clock ~domain:"::1" ~path:"/" ~is_secure:false
2630 in
2631 Alcotest.(check int) "IPv6 exact match" 1 (List.length cookies)
2632
2633let test_ipv6_full_format () =
2634 Eio_mock.Backend.run @@ fun () ->
2635 let clock = Eio_mock.Clock.make () in
2636 Eio_mock.Clock.set_time clock 1000.0;
2637
2638 let jar = create () in
2639 let cookie =
2640 Cookeio.make ~domain:"2001:db8::1" ~path:"/" ~name:"test" ~value:"val"
2641 ~secure:false ~http_only:false ~host_only:false
2642 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2643 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2644 in
2645 add_cookie jar cookie;
2646
2647 (* IPv6 should match exactly *)
2648 let cookies =
2649 get_cookies jar ~clock ~domain:"2001:db8::1" ~path:"/" ~is_secure:false
2650 in
2651 Alcotest.(check int) "IPv6 full format match" 1 (List.length cookies);
2652
2653 (* Different IPv6 should not match *)
2654 let cookies2 =
2655 get_cookies jar ~clock ~domain:"2001:db8::2" ~path:"/" ~is_secure:false
2656 in
2657 Alcotest.(check int) "different IPv6 no match" 0 (List.length cookies2)
2658
2659let test_ip_vs_hostname () =
2660 Eio_mock.Backend.run @@ fun () ->
2661 let clock = Eio_mock.Clock.make () in
2662 Eio_mock.Clock.set_time clock 1000.0;
2663
2664 let jar = create () in
2665
2666 (* Add a hostname cookie with host_only=false (domain cookie) *)
2667 let hostname_cookie =
2668 Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostname" ~value:"h1"
2669 ~secure:false ~http_only:false ~host_only:false
2670 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2671 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2672 in
2673 add_cookie jar hostname_cookie;
2674
2675 (* Add an IP cookie with host_only=false *)
2676 let ip_cookie =
2677 Cookeio.make ~domain:"192.168.1.1" ~path:"/" ~name:"ip" ~value:"i1"
2678 ~secure:false ~http_only:false ~host_only:false
2679 ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
2680 ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
2681 in
2682 add_cookie jar ip_cookie;
2683
2684 (* Hostname request should match hostname cookie and subdomains *)
2685 let cookies1 =
2686 get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
2687 in
2688 Alcotest.(check int) "hostname matches hostname cookie" 1 (List.length cookies1);
2689
2690 let cookies2 =
2691 get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
2692 in
2693 Alcotest.(check int) "subdomain matches hostname cookie" 1 (List.length cookies2);
2694
2695 (* IP request should only match IP cookie exactly *)
2696 let cookies3 =
2697 get_cookies jar ~clock ~domain:"192.168.1.1" ~path:"/" ~is_secure:false
2698 in
2699 Alcotest.(check int) "IP matches IP cookie" 1 (List.length cookies3);
2700 Alcotest.(check string) "IP cookie is returned" "ip" (Cookeio.name (List.hd cookies3))
2701
2702(* ============================================================================ *)
2703(* RFC 6265 Validation Tests *)
2704(* ============================================================================ *)
2705
2706let test_validate_cookie_name_valid () =
2707 (* Valid token characters per RFC 2616 *)
2708 let valid_names = ["session"; "SID"; "my-cookie"; "COOKIE_123"; "abc.def"] in
2709 List.iter (fun name ->
2710 match Cookeio.Validate.cookie_name name with
2711 | Ok _ -> ()
2712 | Error msg ->
2713 Alcotest.fail (Printf.sprintf "Name %S should be valid: %s" name msg))
2714 valid_names
2715
2716let test_validate_cookie_name_invalid () =
2717 (* Invalid: control chars, separators, spaces *)
2718 let invalid_names =
2719 [
2720 ("", "empty");
2721 ("my cookie", "space");
2722 ("cookie=value", "equals");
2723 ("my;cookie", "semicolon");
2724 ("name\t", "tab");
2725 ("(cookie)", "parens");
2726 ("name,val", "comma");
2727 ]
2728 in
2729 List.iter (fun (name, reason) ->
2730 match Cookeio.Validate.cookie_name name with
2731 | Error _ -> () (* Expected *)
2732 | Ok _ ->
2733 Alcotest.fail
2734 (Printf.sprintf "Name %S (%s) should be invalid" name reason))
2735 invalid_names
2736
2737let test_validate_cookie_value_valid () =
2738 (* Valid cookie-octets or quoted values *)
2739 let valid_values = ["abc123"; "value!#$%&'()*+-./"; "\"quoted\""; ""] in
2740 List.iter (fun value ->
2741 match Cookeio.Validate.cookie_value value with
2742 | Ok _ -> ()
2743 | Error msg ->
2744 Alcotest.fail (Printf.sprintf "Value %S should be valid: %s" value msg))
2745 valid_values
2746
2747let test_validate_cookie_value_invalid () =
2748 (* Invalid: space, comma, semicolon, backslash, unmatched quotes *)
2749 let invalid_values =
2750 [
2751 ("with space", "space");
2752 ("with,comma", "comma");
2753 ("with;semi", "semicolon");
2754 ("back\\slash", "backslash");
2755 ("\"unmatched", "unmatched opening quote");
2756 ("unmatched\"", "unmatched closing quote");
2757 ]
2758 in
2759 List.iter (fun (value, reason) ->
2760 match Cookeio.Validate.cookie_value value with
2761 | Error _ -> () (* Expected *)
2762 | Ok _ ->
2763 Alcotest.fail
2764 (Printf.sprintf "Value %S (%s) should be invalid" value reason))
2765 invalid_values
2766
2767let test_validate_domain_valid () =
2768 (* Valid domain names and IP addresses *)
2769 let valid_domains =
2770 ["example.com"; "sub.example.com"; ".example.com"; "192.168.1.1"; "::1"]
2771 in
2772 List.iter (fun domain ->
2773 match Cookeio.Validate.domain_value domain with
2774 | Ok _ -> ()
2775 | Error msg ->
2776 Alcotest.fail (Printf.sprintf "Domain %S should be valid: %s" domain msg))
2777 valid_domains
2778
2779let test_validate_domain_invalid () =
2780 (* Invalid domain names - only test cases that domain-name library rejects.
2781 Note: domain-name library has specific rules that may differ from what
2782 we might expect from the RFC. *)
2783 let invalid_domains =
2784 [
2785 ("", "empty");
2786 (* Note: "-invalid.com" and "invalid-.com" are valid per domain-name library *)
2787 ]
2788 in
2789 List.iter (fun (domain, reason) ->
2790 match Cookeio.Validate.domain_value domain with
2791 | Error _ -> () (* Expected *)
2792 | Ok _ ->
2793 Alcotest.fail
2794 (Printf.sprintf "Domain %S (%s) should be invalid" domain reason))
2795 invalid_domains
2796
2797let test_validate_path_valid () =
2798 let valid_paths = ["/"; "/path"; "/path/to/resource"; "/path?query"] in
2799 List.iter (fun path ->
2800 match Cookeio.Validate.path_value path with
2801 | Ok _ -> ()
2802 | Error msg ->
2803 Alcotest.fail (Printf.sprintf "Path %S should be valid: %s" path msg))
2804 valid_paths
2805
2806let test_validate_path_invalid () =
2807 let invalid_paths =
2808 [
2809 ("/path;bad", "semicolon");
2810 ("/path\x00bad", "control char");
2811 ]
2812 in
2813 List.iter (fun (path, reason) ->
2814 match Cookeio.Validate.path_value path with
2815 | Error _ -> () (* Expected *)
2816 | Ok _ ->
2817 Alcotest.fail
2818 (Printf.sprintf "Path %S (%s) should be invalid" path reason))
2819 invalid_paths
2820
2821let test_duplicate_cookie_detection () =
2822 Eio_mock.Backend.run @@ fun () ->
2823 let clock = Eio_mock.Clock.make () in
2824 Eio_mock.Clock.set_time clock 1000.0;
2825
2826 (* Duplicate cookie names should be rejected *)
2827 let result =
2828 of_cookie_header
2829 ~now:(fun () ->
2830 Ptime.of_float_s (Eio.Time.now clock)
2831 |> Option.value ~default:Ptime.epoch)
2832 ~domain:"example.com" ~path:"/" "session=abc; theme=dark; session=xyz"
2833 in
2834 match result with
2835 | Error msg ->
2836 (* Should mention duplicate *)
2837 let contains_dup = String.lowercase_ascii msg |> fun s ->
2838 try let _ = Str.search_forward (Str.regexp_string "duplicate") s 0 in true
2839 with Not_found -> false
2840 in
2841 Alcotest.(check bool) "error mentions duplicate" true contains_dup
2842 | Ok _ -> Alcotest.fail "Should reject duplicate cookie names"
2843
2844let test_validation_error_messages () =
2845 Eio_mock.Backend.run @@ fun () ->
2846 let clock = Eio_mock.Clock.make () in
2847 Eio_mock.Clock.set_time clock 1000.0;
2848
2849 (* Test that error messages are descriptive *)
2850 let test_cases =
2851 [
2852 ("=noname", "Cookie name is empty");
2853 ("bad cookie=value", "invalid characters");
2854 ("name=val ue", "invalid characters");
2855 ]
2856 in
2857 List.iter (fun (header, expected_substring) ->
2858 match
2859 of_set_cookie_header
2860 ~now:(fun () ->
2861 Ptime.of_float_s (Eio.Time.now clock)
2862 |> Option.value ~default:Ptime.epoch)
2863 ~domain:"example.com" ~path:"/" header
2864 with
2865 | Error msg ->
2866 let has_substring =
2867 try
2868 let _ = Str.search_forward
2869 (Str.regexp_string expected_substring) msg 0 in
2870 true
2871 with Not_found -> false
2872 in
2873 Alcotest.(check bool)
2874 (Printf.sprintf "error for %S mentions %S" header expected_substring)
2875 true has_substring
2876 | Ok _ ->
2877 Alcotest.fail (Printf.sprintf "Should reject %S" header))
2878 test_cases
2879
2880(* ============================================================================ *)
2881(* Public Suffix Validation Tests (RFC 6265 Section 5.3, Step 5) *)
2882(* ============================================================================ *)
2883
2884let test_public_suffix_rejection () =
2885 Eio_mock.Backend.run @@ fun () ->
2886 let clock = Eio_mock.Clock.make () in
2887 Eio_mock.Clock.set_time clock 1000.0;
2888
2889 (* Setting a cookie for a public suffix (TLD) should be rejected *)
2890 let test_cases =
2891 [
2892 (* (request_domain, cookie_domain, description) *)
2893 ("www.example.com", "com", "TLD .com");
2894 ("www.example.co.uk", "co.uk", "ccTLD .co.uk");
2895 ("foo.bar.github.io", "github.io", "private domain github.io");
2896 ]
2897 in
2898
2899 List.iter
2900 (fun (request_domain, cookie_domain, description) ->
2901 let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
2902 let result =
2903 of_set_cookie_header
2904 ~now:(fun () ->
2905 Ptime.of_float_s (Eio.Time.now clock)
2906 |> Option.value ~default:Ptime.epoch)
2907 ~domain:request_domain ~path:"/" header
2908 in
2909 match result with
2910 | Error msg ->
2911 (* Should mention public suffix *)
2912 let has_psl =
2913 String.lowercase_ascii msg |> fun s ->
2914 try
2915 let _ = Str.search_forward (Str.regexp_string "public suffix") s 0 in
2916 true
2917 with Not_found -> false
2918 in
2919 Alcotest.(check bool)
2920 (Printf.sprintf "%s: error mentions public suffix" description)
2921 true has_psl
2922 | Ok _ ->
2923 Alcotest.fail
2924 (Printf.sprintf "Should reject cookie for %s" description))
2925 test_cases
2926
2927let test_public_suffix_allowed_when_exact_match () =
2928 Eio_mock.Backend.run @@ fun () ->
2929 let clock = Eio_mock.Clock.make () in
2930 Eio_mock.Clock.set_time clock 1000.0;
2931
2932 (* If request host exactly matches the public suffix domain, allow it.
2933 This is rare but possible for private domains like blogspot.com *)
2934 let header = "session=abc; Domain=.blogspot.com" in
2935 let result =
2936 of_set_cookie_header
2937 ~now:(fun () ->
2938 Ptime.of_float_s (Eio.Time.now clock)
2939 |> Option.value ~default:Ptime.epoch)
2940 ~domain:"blogspot.com" ~path:"/" header
2941 in
2942 Alcotest.(check bool)
2943 "exact match allows public suffix" true
2944 (Result.is_ok result)
2945
2946let test_non_public_suffix_allowed () =
2947 Eio_mock.Backend.run @@ fun () ->
2948 let clock = Eio_mock.Clock.make () in
2949 Eio_mock.Clock.set_time clock 1000.0;
2950
2951 (* Normal domain (not a public suffix) should be allowed *)
2952 let test_cases =
2953 [
2954 ("www.example.com", "example.com", "registrable domain");
2955 ("sub.example.com", "example.com", "parent of subdomain");
2956 ("www.example.co.uk", "example.co.uk", "registrable domain under ccTLD");
2957 ]
2958 in
2959
2960 List.iter
2961 (fun (request_domain, cookie_domain, description) ->
2962 let header = Printf.sprintf "session=abc; Domain=.%s" cookie_domain in
2963 let result =
2964 of_set_cookie_header
2965 ~now:(fun () ->
2966 Ptime.of_float_s (Eio.Time.now clock)
2967 |> Option.value ~default:Ptime.epoch)
2968 ~domain:request_domain ~path:"/" header
2969 in
2970 match result with
2971 | Ok cookie ->
2972 Alcotest.(check string)
2973 (Printf.sprintf "%s: domain correct" description)
2974 cookie_domain (Cookeio.domain cookie)
2975 | Error msg ->
2976 Alcotest.fail
2977 (Printf.sprintf "%s should be allowed: %s" description msg))
2978 test_cases
2979
2980let test_public_suffix_no_domain_attribute () =
2981 Eio_mock.Backend.run @@ fun () ->
2982 let clock = Eio_mock.Clock.make () in
2983 Eio_mock.Clock.set_time clock 1000.0;
2984
2985 (* Cookie without Domain attribute should always be allowed (host-only) *)
2986 let header = "session=abc; Secure; HttpOnly" in
2987 let result =
2988 of_set_cookie_header
2989 ~now:(fun () ->
2990 Ptime.of_float_s (Eio.Time.now clock)
2991 |> Option.value ~default:Ptime.epoch)
2992 ~domain:"www.example.com" ~path:"/" header
2993 in
2994 match result with
2995 | Ok cookie ->
2996 Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
2997 Alcotest.(check string)
2998 "domain is request domain" "www.example.com"
2999 (Cookeio.domain cookie)
3000 | Error msg -> Alcotest.fail ("Should allow host-only cookie: " ^ msg)
3001
3002let test_public_suffix_ip_address_bypass () =
3003 Eio_mock.Backend.run @@ fun () ->
3004 let clock = Eio_mock.Clock.make () in
3005 Eio_mock.Clock.set_time clock 1000.0;
3006
3007 (* IP addresses should bypass PSL check *)
3008 let header = "session=abc; Domain=192.168.1.1" in
3009 let result =
3010 of_set_cookie_header
3011 ~now:(fun () ->
3012 Ptime.of_float_s (Eio.Time.now clock)
3013 |> Option.value ~default:Ptime.epoch)
3014 ~domain:"192.168.1.1" ~path:"/" header
3015 in
3016 Alcotest.(check bool)
3017 "IP address bypasses PSL" true
3018 (Result.is_ok result)
3019
3020let test_public_suffix_case_insensitive () =
3021 Eio_mock.Backend.run @@ fun () ->
3022 let clock = Eio_mock.Clock.make () in
3023 Eio_mock.Clock.set_time clock 1000.0;
3024
3025 (* Public suffix check should be case-insensitive *)
3026 let header = "session=abc; Domain=.COM" in
3027 let result =
3028 of_set_cookie_header
3029 ~now:(fun () ->
3030 Ptime.of_float_s (Eio.Time.now clock)
3031 |> Option.value ~default:Ptime.epoch)
3032 ~domain:"www.example.COM" ~path:"/" header
3033 in
3034 Alcotest.(check bool)
3035 "uppercase TLD still rejected" true
3036 (Result.is_error result)
3037
3038let () =
3039 Eio_main.run @@ fun env ->
3040 let open Alcotest in
3041 run "Cookeio Tests"
3042 [
3043 ( "mozilla_format",
3044 [
3045 test_case "Load Mozilla format from string" `Quick (fun () ->
3046 test_load_mozilla_cookies env);
3047 test_case "Load Mozilla format from file" `Quick (fun () ->
3048 test_load_from_file env);
3049 test_case "Round trip Mozilla format" `Quick (fun () ->
3050 test_round_trip_mozilla_format env);
3051 ] );
3052 ( "cookie_matching",
3053 [
3054 test_case "Domain and security matching" `Quick (fun () ->
3055 test_cookie_matching env);
3056 ] );
3057 ( "basic_operations",
3058 [
3059 test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env);
3060 ] );
3061 ( "time_handling",
3062 [
3063 test_case "Cookie expiry with mock clock" `Quick
3064 test_cookie_expiry_with_mock_clock;
3065 test_case "get_cookies filters expired cookies" `Quick
3066 test_get_cookies_filters_expired;
3067 test_case "Max-Age parsing with mock clock" `Quick
3068 test_max_age_parsing_with_mock_clock;
3069 test_case "Last access time with mock clock" `Quick
3070 test_last_access_time_with_mock_clock;
3071 test_case "Parse Set-Cookie with Expires" `Quick
3072 test_of_set_cookie_header_with_expires;
3073 test_case "SameSite=None validation" `Quick
3074 test_samesite_none_validation;
3075 ] );
3076 ( "domain_normalization",
3077 [
3078 test_case "Domain normalization" `Quick test_domain_normalization;
3079 test_case "Domain matching with normalized domains" `Quick
3080 test_domain_matching;
3081 ] );
3082 ( "max_age_tracking",
3083 [
3084 test_case "Max-Age stored separately from Expires" `Quick
3085 test_max_age_stored_separately;
3086 test_case "Negative Max-Age becomes 0" `Quick
3087 test_max_age_negative_becomes_zero;
3088 test_case "make_set_cookie_header includes Max-Age" `Quick
3089 test_make_set_cookie_header_includes_max_age;
3090 test_case "Max-Age round-trip parsing" `Quick test_max_age_round_trip;
3091 ] );
3092 ( "delta_tracking",
3093 [
3094 test_case "add_original doesn't affect delta" `Quick
3095 test_add_original_not_in_delta;
3096 test_case "add_cookie appears in delta" `Quick
3097 test_add_cookie_appears_in_delta;
3098 test_case "remove original creates removal cookie" `Quick
3099 test_remove_original_creates_removal_cookie;
3100 test_case "remove delta cookie just removes it" `Quick
3101 test_remove_delta_cookie_removes_it;
3102 test_case "get_cookies combines original and delta" `Quick
3103 test_get_cookies_combines_original_and_delta;
3104 test_case "get_cookies delta takes precedence" `Quick
3105 test_get_cookies_delta_takes_precedence;
3106 test_case "get_cookies excludes removal cookies" `Quick
3107 test_get_cookies_excludes_removal_cookies;
3108 test_case "delta returns only changed cookies" `Quick
3109 test_delta_returns_only_changed_cookies;
3110 test_case "removal cookie format" `Quick test_removal_cookie_format;
3111 ] );
3112 ( "http_date_parsing",
3113 [
3114 test_case "HTTP date FMT1 (RFC 1123)" `Quick test_http_date_fmt1;
3115 test_case "HTTP date FMT2 (RFC 850)" `Quick test_http_date_fmt2;
3116 test_case "HTTP date FMT3 (asctime)" `Quick test_http_date_fmt3;
3117 test_case "HTTP date FMT4 (variant)" `Quick test_http_date_fmt4;
3118 test_case "Abbreviated year 69-99 becomes 1900+" `Quick
3119 test_abbreviated_year_69_to_99;
3120 test_case "Abbreviated year 0-68 becomes 2000+" `Quick
3121 test_abbreviated_year_0_to_68;
3122 test_case "RFC 3339 backward compatibility" `Quick
3123 test_rfc3339_still_works;
3124 test_case "Invalid date format logs warning" `Quick
3125 test_invalid_date_format_logs_warning;
3126 test_case "Case-insensitive month parsing" `Quick
3127 test_case_insensitive_month_parsing;
3128 test_case "Case-insensitive GMT parsing" `Quick
3129 test_case_insensitive_gmt_parsing;
3130 ] );
3131 ( "partitioned",
3132 [
3133 test_case "parse partitioned cookie" `Quick (fun () ->
3134 test_partitioned_parsing env);
3135 test_case "serialize partitioned cookie" `Quick (fun () ->
3136 test_partitioned_serialization env);
3137 test_case "partitioned requires secure" `Quick (fun () ->
3138 test_partitioned_requires_secure env);
3139 ] );
3140 ( "expiration",
3141 [
3142 test_case "expiration variants" `Quick (fun () ->
3143 test_expiration_variants env);
3144 test_case "parse session expiration" `Quick (fun () ->
3145 test_parse_session_expiration env);
3146 test_case "serialize expiration variants" `Quick (fun () ->
3147 test_serialize_expiration_variants env);
3148 ] );
3149 ( "value_trimming",
3150 [
3151 test_case "quoted values" `Quick (fun () ->
3152 test_quoted_cookie_values env);
3153 test_case "trimmed not used for equality" `Quick (fun () ->
3154 test_trimmed_value_not_used_for_equality env);
3155 ] );
3156 ( "cookie_header",
3157 [
3158 test_case "parse basic" `Quick (fun () ->
3159 test_cookie_header_parsing_basic env);
3160 test_case "default values" `Quick (fun () ->
3161 test_cookie_header_defaults env);
3162 test_case "edge cases" `Quick (fun () ->
3163 test_cookie_header_edge_cases env);
3164 test_case "multiple with errors" `Quick (fun () ->
3165 test_cookie_header_with_errors env);
3166 ] );
3167 ( "max_age_expires_interaction",
3168 [
3169 test_case "both present" `Quick (fun () ->
3170 test_max_age_and_expires_both_present env);
3171 test_case "parse both" `Quick (fun () ->
3172 test_parse_max_age_and_expires env);
3173 ] );
3174 ( "host_only_flag",
3175 [
3176 test_case "host_only without Domain attribute" `Quick
3177 test_host_only_without_domain_attribute;
3178 test_case "host_only with Domain attribute" `Quick
3179 test_host_only_with_domain_attribute;
3180 test_case "host_only with dotted Domain attribute" `Quick
3181 test_host_only_with_dotted_domain_attribute;
3182 test_case "host_only domain matching" `Quick
3183 test_host_only_domain_matching;
3184 test_case "host_only Cookie header parsing" `Quick
3185 test_host_only_cookie_header_parsing;
3186 test_case "host_only Mozilla format round trip" `Quick
3187 test_host_only_mozilla_format_round_trip;
3188 ] );
3189 ( "path_matching",
3190 [
3191 test_case "identical path" `Quick test_path_matching_identical;
3192 test_case "path with trailing slash" `Quick
3193 test_path_matching_with_trailing_slash;
3194 test_case "prefix with slash separator" `Quick
3195 test_path_matching_prefix_with_slash;
3196 test_case "no false prefix match" `Quick
3197 test_path_matching_no_false_prefix;
3198 test_case "root path matches all" `Quick test_path_matching_root;
3199 test_case "path no match" `Quick test_path_matching_no_match;
3200 ] );
3201 ( "ip_address_matching",
3202 [
3203 test_case "IPv4 exact match" `Quick test_ipv4_exact_match;
3204 test_case "IPv4 no suffix match" `Quick test_ipv4_no_suffix_match;
3205 test_case "IPv4 different IP no match" `Quick test_ipv4_different_ip;
3206 test_case "IPv6 exact match" `Quick test_ipv6_exact_match;
3207 test_case "IPv6 full format" `Quick test_ipv6_full_format;
3208 test_case "IP vs hostname behavior" `Quick test_ip_vs_hostname;
3209 ] );
3210 ( "rfc6265_validation",
3211 [
3212 test_case "valid cookie names" `Quick test_validate_cookie_name_valid;
3213 test_case "invalid cookie names" `Quick test_validate_cookie_name_invalid;
3214 test_case "valid cookie values" `Quick test_validate_cookie_value_valid;
3215 test_case "invalid cookie values" `Quick test_validate_cookie_value_invalid;
3216 test_case "valid domain values" `Quick test_validate_domain_valid;
3217 test_case "invalid domain values" `Quick test_validate_domain_invalid;
3218 test_case "valid path values" `Quick test_validate_path_valid;
3219 test_case "invalid path values" `Quick test_validate_path_invalid;
3220 test_case "duplicate cookie detection" `Quick test_duplicate_cookie_detection;
3221 test_case "validation error messages" `Quick test_validation_error_messages;
3222 ] );
3223 ( "cookie_ordering",
3224 [
3225 test_case "ordering by path length" `Quick
3226 test_cookie_ordering_by_path_length;
3227 test_case "ordering by creation time" `Quick
3228 test_cookie_ordering_by_creation_time;
3229 test_case "ordering combined" `Quick test_cookie_ordering_combined;
3230 ] );
3231 ( "creation_time_preservation",
3232 [
3233 test_case "preserved on update" `Quick
3234 test_creation_time_preserved_on_update;
3235 test_case "preserved in add_original" `Quick
3236 test_creation_time_preserved_add_original;
3237 test_case "new cookie keeps time" `Quick test_creation_time_new_cookie;
3238 ] );
3239 ( "public_suffix_validation",
3240 [
3241 test_case "reject public suffix domains" `Quick
3242 test_public_suffix_rejection;
3243 test_case "allow exact match on public suffix" `Quick
3244 test_public_suffix_allowed_when_exact_match;
3245 test_case "allow non-public-suffix domains" `Quick
3246 test_non_public_suffix_allowed;
3247 test_case "no Domain attribute bypasses PSL" `Quick
3248 test_public_suffix_no_domain_attribute;
3249 test_case "IP address bypasses PSL" `Quick
3250 test_public_suffix_ip_address_bypass;
3251 test_case "case insensitive check" `Quick
3252 test_public_suffix_case_insensitive;
3253 ] );
3254 ]