View raw

1 (** Shared support for the web tier tests. Sessions and CSRF are exercised end 2 to end: a small client carries the session cookie between requests and pulls 3 the CSRF token out of the rendered page, exactly as a browser would. The 4 SQLite runs in an isolated in-memory database; session storage is Dream's 5 in-memory back end. *) 6 7 open Hito_app 8 9 module Handlers = 10 Hito_web.Handlers.Make (Test_repo) (Test_hash) (Hito_import.Hevy_csv) 11 12 (* A fresh, fully wired application: secret, in-memory sessions, routes. 13 Registration is opened here so the auth-flow tests can create trainees via 14 [/register]; production keeps it closed. *) 15 let app ?(import_open = true) () = 16 let handlers = 17 Handlers.make ~repo:(Test_repo.create ()) ~registration_open:true 18 ~import_open () 19 in 20 Dream.memory_sessions @@ Handlers.security_headers 21 @@ Dream.router (Handlers.routes handlers) 22 |> fun handler -> Dream.set_secret "test-secret-value" handler 23 24 let status response = Dream.status response |> Dream.status_to_int 25 let body response = Lwt_main.run (Dream.body response) 26 27 (* The redirect target of a 3xx response — the URL half of a URL-to-URL flow. *) 28 let location response = 29 match Dream.header response "Location" with 30 | Some target -> target 31 | None -> Alcotest.failf "expected a Location header on a redirect" 32 33 (* --- a cookie-carrying client --- *) 34 35 (* Extract cookies (name=value) from all Set-Cookie response headers. *) 36 let cookies_of response = 37 Dream.headers response "Set-Cookie" 38 |> List.filter_map (fun sc -> 39 match String.index_opt sc ';' with 40 | Some i -> Some (String.sub sc 0 i) 41 | None -> Some sc) 42 43 type client = { app : Dream.handler; mutable jar : string list } 44 45 let client ?(import_open = true) () = { app = app ~import_open (); jar = [] } 46 47 let cookie_header client = 48 if client.jar = [] then [] else [ ("Cookie", String.concat "; " client.jar) ] 49 50 let remember client response = 51 (* Replace cookies of the same name; keep the rest. *) 52 List.iter 53 (fun fresh -> 54 let name = 55 match String.index_opt fresh '=' with 56 | Some i -> String.sub fresh 0 i 57 | None -> fresh 58 in 59 client.jar <- 60 fresh 61 :: List.filter 62 (fun old -> 63 not 64 (String.length old >= String.length name 65 && String.sub old 0 (String.length name) = name 66 && (String.length old = String.length name 67 || old.[String.length name] = '='))) 68 client.jar) 69 (cookies_of response) 70 71 let get client target = 72 let response = 73 Dream.test client.app 74 (Dream.request ~method_:`GET ~target ~headers:(cookie_header client) "") 75 in 76 remember client response; 77 response 78 79 let post client target fields = 80 let body = 81 fields 82 |> List.map (fun (k, v) -> 83 Dream.to_percent_encoded k ^ "=" ^ Dream.to_percent_encoded v) 84 |> String.concat "&" 85 in 86 let response = 87 Dream.test client.app 88 (Dream.request ~method_:`POST ~target 89 ~headers: 90 (("Content-Type", "application/x-www-form-urlencoded") 91 :: cookie_header client) 92 body) 93 in 94 remember client response; 95 response 96 97 (* A multipart/form-data POST. [file] is [(field, filename, content)]; [fields] 98 are plain text parts. The CSRF token rides as its own text part, exactly as 99 [Dream.csrf_tag] renders it into a browser form, so [Dream.multipart ~csrf] 100 verifies it. The boundary is fixed but improbable in the body. *) 101 let post_multipart client target ~file ~fields = 102 let boundary = "----hitotestboundary7Xk2p" in 103 let buf = Buffer.create 1024 in 104 let part_field (name, value) = 105 Buffer.add_string buf ("--" ^ boundary ^ "\r\n"); 106 Buffer.add_string buf 107 (Printf.sprintf "Content-Disposition: form-data; name=%S\r\n\r\n" name); 108 Buffer.add_string buf value; 109 Buffer.add_string buf "\r\n" 110 in 111 List.iter part_field fields; 112 (match file with 113 | Some (name, filename, content) -> 114 Buffer.add_string buf ("--" ^ boundary ^ "\r\n"); 115 Buffer.add_string buf 116 (Printf.sprintf 117 "Content-Disposition: form-data; name=%S; filename=%S\r\n" name 118 filename); 119 Buffer.add_string buf "Content-Type: text/csv\r\n\r\n"; 120 Buffer.add_string buf content; 121 Buffer.add_string buf "\r\n" 122 | None -> ()); 123 Buffer.add_string buf ("--" ^ boundary ^ "--\r\n"); 124 let response = 125 Dream.test client.app 126 (Dream.request ~method_:`POST ~target 127 ~headers: 128 (("Content-Type", "multipart/form-data; boundary=" ^ boundary) 129 :: cookie_header client) 130 (Buffer.contents buf)) 131 in 132 remember client response; 133 response 134 135 let index_from ~needle haystack start = 136 let n = String.length needle and h = String.length haystack in 137 let rec at i = 138 if i + n > h then None 139 else if String.sub haystack i n = needle then Some i 140 else at (i + 1) 141 in 142 if start < 0 then None else at start 143 144 (* Extract the CSRF token from a rendered form. The test client needs this 145 browser-facing value to submit the same protected requests as a user. *) 146 let csrf_token html = 147 match index_from ~needle:"name=\"dream.csrf\"" html 0 with 148 | None -> None 149 | Some idx -> ( 150 match index_from ~needle:"value=\"" html idx with 151 | None -> None 152 | Some v -> 153 let start = v + String.length "value=\"" in 154 let stop = String.index_from html start '"' in 155 Some (String.sub html start (stop - start))) 156 157 let register client ~username ~password = 158 let page = body (get client "/register") in 159 let token = Option.get (csrf_token page) in 160 post client "/register" 161 [ ("dream.csrf", token); ("username", username); ("password", password) ] 162 163 let sign_in_new client = 164 register client ~username:"lifter" ~password:"heavyduty1" 165 166 (* Sign in through the credential form, as a returning trainee would. Unlike 167 [register], this exercises the [POST /login] path. *) 168 let sign_in client ~username ~password = 169 let page = body (get client "/login") in 170 let token = Option.get (csrf_token page) in 171 post client "/login" 172 [ ("dream.csrf", token); ("username", username); ("password", password) ] 173 174 (* An application whose clock the test drives. A shared [Test_repo] and a 175 mutable [now] cell let a deep test advance time across the recovery gate, 176 which the wall-clock default cannot. Registration is open so the test can 177 create the trainee it drives. Also hands back the repository, so a deep test 178 can assert the domain state its flow produced. *) 179 let clocked_client () = 180 let now = ref 0 in 181 let repo = Test_repo.create () in 182 let handlers = 183 Handlers.make ~repo 184 ~now:(fun () -> Recovery.timestamp_of_unix_seconds !now) 185 ~registration_open:true () 186 in 187 let app = 188 Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun h -> 189 Dream.set_secret "test-secret-value" h 190 in 191 ({ app; jar = [] }, now, repo) 192 193 let day n = n * 86_400 194 195 (* A client that hands back its repository, so a test can assert the domain 196 state the flow produced — the log, the stored feedback, the renamed account — 197 rather than reading it back out of rendered HTML. *) 198 let client_with_repo ?(import_open = true) () = 199 let repo = Test_repo.create () in 200 let handlers = Handlers.make ~repo ~registration_open:true ~import_open () in 201 let app = 202 Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun h -> 203 Dream.set_secret "test-secret-value" h 204 in 205 ({ app; jar = [] }, repo) 206 207 (* Look up the sole seeded trainee by the name the flow helpers register. *) 208 let the_trainee repo ~username = 209 match Trainee.username username with 210 | Error _ -> Alcotest.failf "test username %S is invalid" username 211 | Ok u -> ( 212 match Lwt_main.run (Test_repo.find_trainee_by_username repo u) with 213 | Some trainee -> trainee 214 | None -> Alcotest.failf "no trainee named %S" username) 215 216 (* Drive a trainee to the start of a fresh Day 1 workout: select the ideal 217 routine and begin without an override. Returns the client. *) 218 let start_day_one c = 219 let token = Option.get (csrf_token (body (get c "/"))) in 220 let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in 221 let token = Option.get (csrf_token (body (get c "/"))) in 222 post c "/workout" [ ("dream.csrf", token); ("override", "false") ] 223 224 (* Record every slot of the in-progress Day 1 workout, one at a time, addressed 225 by [?slot=]. Slot 0 is the flyes/incline-press pair; slot 3 is the 226 french-press/dips pair; slots 1 and 2 are single lateral movements. *) 227 let record_all_day_one_slots c = 228 let record_pair slot comp_load = 229 let token = 230 Option.get (csrf_token (body (get c ("/workout?slot=" ^ slot)))) 231 in 232 post c ("/workout/slots/" ^ slot) 233 [ 234 ("dream.csrf", token); 235 ("iso_load", "20"); 236 ("iso_reps", "9"); 237 ("comp_load", comp_load); 238 ("comp_reps", "7"); 239 ("extension", ""); 240 ] 241 in 242 let record_single slot load reps = 243 let token = 244 Option.get (csrf_token (body (get c ("/workout?slot=" ^ slot)))) 245 in 246 post c ("/workout/slots/" ^ slot) 247 [ 248 ("dream.csrf", token); ("load", load); ("reps", reps); ("extension", ""); 249 ] 250 in 251 let _ = record_pair "0" "60" in 252 let _ = record_single "1" "12" "8" in 253 let _ = record_single "2" "10" "9" in 254 let _ = record_pair "3" "0" in 255 () 256