(** Web tier tests. Sessions and CSRF are exercised end to end: a small client carries the session cookie between requests and pulls the CSRF token out of the rendered page, exactly as a browser would. The in-memory repository stands in for SQLite; session storage is Dream's in-memory back end. *) open Hito_app module Handlers = Hito_web.Handlers.Make (Memory_repo) (* A fresh, fully wired application: secret, in-memory sessions, routes. Registration is opened here so the auth-flow tests can create trainees via [/register]; production keeps it closed. *) let app () = let handlers = Handlers.make ~repo:(Memory_repo.create ()) ~registration_open:true () in Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun handler -> Dream.set_secret "test-secret-value" handler let status response = Dream.status response |> Dream.status_to_int let body response = Lwt_main.run (Dream.body response) let contains ~substring string = let n = String.length substring in let rec at i = if i + n > String.length string then false else if String.sub string i n = substring then true else at (i + 1) in at 0 (* --- a cookie-carrying client --- *) (* Extract cookies (name=value) from all Set-Cookie response headers. *) let cookies_of response = Dream.headers response "Set-Cookie" |> List.filter_map (fun sc -> match String.index_opt sc ';' with | Some i -> Some (String.sub sc 0 i) | None -> Some sc) type client = { app : Dream.handler; mutable jar : string list } let client () = { app = app (); jar = [] } let cookie_header client = if client.jar = [] then [] else [ ("Cookie", String.concat "; " client.jar) ] let remember client response = (* Replace cookies of the same name; keep the rest. *) List.iter (fun fresh -> let name = match String.index_opt fresh '=' with | Some i -> String.sub fresh 0 i | None -> fresh in client.jar <- fresh :: List.filter (fun old -> not (String.length old >= String.length name && String.sub old 0 (String.length name) = name && (String.length old = String.length name || old.[String.length name] = '='))) client.jar) (cookies_of response) let get client target = let response = Dream.test client.app (Dream.request ~method_:`GET ~target ~headers:(cookie_header client) "") in remember client response; response let post client target fields = let body = fields |> List.map (fun (k, v) -> Dream.to_percent_encoded k ^ "=" ^ Dream.to_percent_encoded v) |> String.concat "&" in let response = Dream.test client.app (Dream.request ~method_:`POST ~target ~headers: (("Content-Type", "application/x-www-form-urlencoded") :: cookie_header client) body) in remember client response; response (* A tiny substring search, since Str is not a dependency here. *) let index_from ~needle haystack start = let n = String.length needle and h = String.length haystack in let rec at i = if i + n > h then None else if String.sub haystack i n = needle then Some i else at (i + 1) in if start < 0 then None else at start (* Pull the CSRF token value out of a rendered form. *) let csrf_token html = match index_from ~needle:"name=\"dream.csrf\"" html 0 with | None -> None | Some idx -> ( match index_from ~needle:"value=\"" html idx with | None -> None | Some v -> let start = v + String.length "value=\"" in let stop = String.index_from html start '"' in Some (String.sub html start (stop - start))) let register client ~username ~password = let page = body (get client "/register") in let token = Option.get (csrf_token page) in post client "/register" [ ("dream.csrf", token); ("username", username); ("password", password) ] let sign_in_new client = register client ~username:"lifter" ~password:"heavyduty1" (* Sign in through the credential form, as a returning trainee would. Unlike [register], this exercises the [POST /login] path. *) let sign_in client ~username ~password = let page = body (get client "/login") in let token = Option.get (csrf_token page) in post client "/login" [ ("dream.csrf", token); ("username", username); ("password", password) ] (* An application whose clock the test drives. A shared [Memory_repo] and a mutable [now] cell let a deep test advance time across the recovery gate, which the wall-clock default cannot. Registration is open so the test can create the trainee it drives. *) let clocked_client () = let now = ref 0 in let handlers = Handlers.make ~repo:(Memory_repo.create ()) ~now:(fun () -> Recovery.timestamp_of_unix_seconds !now) ~registration_open:true () in let app = Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun h -> Dream.set_secret "test-secret-value" h in ({ app; jar = [] }, now) let day n = n * 86_400 (* Drive a trainee to the start of a fresh Day 1 workout: select the ideal routine and begin without an override. Returns the client. *) let start_day_one c = let token = Option.get (csrf_token (body (get c "/"))) in let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in let token = Option.get (csrf_token (body (get c "/"))) in post c "/workout" [ ("dream.csrf", token); ("override", "false") ] (* Record every slot of the in-progress Day 1 workout, one at a time, addressed by [?slot=]. Slot 0 is the flyes/incline-press pair; slot 3 is the french-press/dips pair; slots 1 and 2 are single lateral movements. *) let record_all_day_one_slots c = let record_pair slot comp_load = let token = Option.get (csrf_token (body (get c ("/workout?slot=" ^ slot)))) in post c ("/workout/slots/" ^ slot) [ ("dream.csrf", token); ("iso_load", "20"); ("iso_reps", "9"); ("comp_load", comp_load); ("comp_reps", "7"); ("extension", ""); ] in let record_single slot load reps = let token = Option.get (csrf_token (body (get c ("/workout?slot=" ^ slot)))) in post c ("/workout/slots/" ^ slot) [ ("dream.csrf", token); ("load", load); ("reps", reps); ("extension", ""); ] in let _ = record_pair "0" "60" in let _ = record_single "1" "12" "8" in let _ = record_single "2" "10" "9" in let _ = record_pair "3" "0" in () let route_tests = [ ( "web.layout", [ ( "the desktop main surface is narrower than the shell", `Quick, fun () -> let c = client () in let stylesheet = body (get c "/assets/hito.css") in Alcotest.(check bool) "desktop page surface cap" true (contains ~substring:"@media (min-width: 42.0625rem)" stylesheet && contains ~substring:"max-width: 52rem" stylesheet && contains ~substring:"margin-inline: auto" stylesheet) ); ] ); ( "web.auth", [ ( "an unauthenticated visit to the overview redirects to sign-in", `Quick, fun () -> let c = client () in let response = get c "/" in Alcotest.(check int) "redirect" 303 (status response) ); ( "the sign-in page renders a CSRF-protected form", `Quick, fun () -> let c = client () in let page = body (get c "/login") in Alcotest.(check bool) "has csrf field" true (contains ~substring:"dream.csrf" page); Alcotest.(check bool) "has a password field" true (contains ~substring:"type=\"password\"" page); Alcotest.(check bool) "starts the main content with a page-level heading" true (contains ~substring:"