(** Router error handling: that invalid names, hashes and missing objects produce the intended failure category. *) open Test_helpers let test_invalid_repo_name () = with_temp_directory "ogit-router" (fun root -> let config = Ogit.Config.{ default with git_project_root = root } in let request = Dream.test (Dream.router (Ogit.Handlers.routes config)) in let status = Dream.request ~target:"/.hidden/summary/" "" |> request |> Dream.status in Alcotest.(check int) "400" 400 (Dream.status_to_int status)) let test_missing_repo () = with_temp_directory "ogit-router" (fun root -> let config = Ogit.Config.{ default with git_project_root = root } in let request = Dream.test (Dream.router (Ogit.Handlers.routes config)) in let status = Dream.request ~target:"/missing/summary/" "" |> request |> Dream.status in Alcotest.(check int) "404" 404 (Dream.status_to_int status)) let test_invalid_hash () = with_temp_directory "ogit-router" (fun root -> let name = "project" in let path = Filename.concat root name in Unix.mkdir path 0o755; ignore (git [ "-C"; path; "init"; "-q"; "-b"; "main" ]); ignore (git [ "-C"; path; "config"; "user.name"; "Test" ]); ignore (git [ "-C"; path; "config"; "user.email"; "t@t.invalid" ]); Out_channel.with_open_text (Filename.concat path "f.txt") (fun ch -> output_string ch "x\n"); ignore (git [ "-C"; path; "add"; "." ]); ignore (git [ "-C"; path; "commit"; "-q"; "-m"; "init" ]); let config = Ogit.Config.{ default with git_project_root = root } in let request = Dream.test (Dream.router (Ogit.Handlers.routes config)) in let status = Dream.request ~target:"/project/commit/not-a-hash" "" |> request |> Dream.status in Alcotest.(check int) "400" 400 (Dream.status_to_int status)) let test_missing_object () = with_temp_directory "ogit-router" (fun root -> let name = "project" in let path = Filename.concat root name in Unix.mkdir path 0o755; ignore (git [ "-C"; path; "init"; "-q"; "-b"; "main" ]); ignore (git [ "-C"; path; "config"; "user.name"; "Test" ]); ignore (git [ "-C"; path; "config"; "user.email"; "t@t.invalid" ]); Out_channel.with_open_text (Filename.concat path "f.txt") (fun ch -> output_string ch "x\n"); ignore (git [ "-C"; path; "add"; "." ]); ignore (git [ "-C"; path; "commit"; "-q"; "-m"; "init" ]); let config = Ogit.Config.{ default with git_project_root = root } in let request = Dream.test (Dream.router (Ogit.Handlers.routes config)) in let target = "/project/commit/" ^ String.make 40 'a' in let status = Dream.request ~target "" |> request |> Dream.status in Alcotest.(check int) "404" 404 (Dream.status_to_int status)) (* Raw responses must never let repository content run with the site's authority: the sandbox policy and nosniff header are the contract. An SVG is used because it is the content type that made the headers necessary. *) let test_raw_response_headers () = with_temp_directory "ogit-router" (fun root -> let name = "project" in let path = Filename.concat root name in Unix.mkdir path 0o755; ignore (git [ "-C"; path; "init"; "-q"; "-b"; "main" ]); ignore (git [ "-C"; path; "config"; "user.name"; "Test" ]); ignore (git [ "-C"; path; "config"; "user.email"; "t@t.invalid" ]); Out_channel.with_open_text (Filename.concat path "img.svg") (fun ch -> output_string ch "\n"); ignore (git [ "-C"; path; "add"; "." ]); ignore (git [ "-C"; path; "commit"; "-q"; "-m"; "init" ]); let blob_hash = git [ "-C"; path; "rev-parse"; "HEAD:img.svg" ] in let config = Ogit.Config.{ default with git_project_root = root } in let request = Dream.test (Dream.router (Ogit.Handlers.routes config)) in let target = "/project/raw/" ^ blob_hash in let response = Dream.request ~target "" |> request in Alcotest.(check int) "200" 200 (Dream.status_to_int (Dream.status response)); let header name = Dream.header response name |> Option.value ~default:"" in Alcotest.(check string) "content type" "image/svg+xml" (header "Content-Type"); Alcotest.(check string) "sandboxed" "sandbox" (header "Content-Security-Policy"); Alcotest.(check string) "nosniff" "nosniff" (header "X-Content-Type-Options")) (* Files are addressable by their path from the repository root, for both the rendered page and the raw content. *) let test_file_by_path () = with_temp_directory "ogit-router" (fun root -> let name = "project" in let path = Filename.concat root name in Unix.mkdir path 0o755; ignore (git [ "-C"; path; "init"; "-q"; "-b"; "main" ]); ignore (git [ "-C"; path; "config"; "user.name"; "Test" ]); ignore (git [ "-C"; path; "config"; "user.email"; "t@t.invalid" ]); let dir = Filename.concat path "dir" in Unix.mkdir dir 0o755; Out_channel.with_open_text (Filename.concat dir "nested.txt") (fun ch -> output_string ch "nested content\n"); ignore (git [ "-C"; path; "add"; "." ]); ignore (git [ "-C"; path; "commit"; "-q"; "-m"; "init" ]); let config = Ogit.Config.{ default with git_project_root = root } in let request = Dream.test (Dream.router (Ogit.Handlers.routes config)) in let status target = Dream.request ~target "" |> request |> Dream.status |> Dream.status_to_int in Alcotest.(check int) "blob page" 200 (status "/project/file/dir/nested.txt"); Alcotest.(check int) "tree page" 200 (status "/project/file/dir"); Alcotest.(check int) "raw blob" 200 (status "/project/raw/dir/nested.txt"); Alcotest.(check int) "missing path" 404 (status "/project/file/dir/none"); Alcotest.(check int) "raw tree" 400 (status "/project/raw/dir")) (* The page cache is keyed on the repository's head hash: a new commit must be visible immediately, because it changes the key rather than the entry. *) let test_cache_sees_new_commit () = with_temp_directory "ogit-router" (fun root -> let name = "project" in let path = Filename.concat root name in Unix.mkdir path 0o755; ignore (git [ "-C"; path; "init"; "-q"; "-b"; "main" ]); ignore (git [ "-C"; path; "config"; "user.name"; "Test" ]); ignore (git [ "-C"; path; "config"; "user.email"; "t@t.invalid" ]); let write content = Out_channel.with_open_text (Filename.concat path "f.txt") (fun ch -> output_string ch content) in let commit message = ignore (git [ "-C"; path; "add"; "." ]); ignore (git [ "-C"; path; "commit"; "-q"; "-m"; message ]) in write "generation-one\n"; commit "first"; let config = Ogit.Config.{ default with git_project_root = root } in let request = Dream.test (Dream.router (Ogit.Handlers.routes config)) in let body target = Dream.request ~target "" |> request |> Dream.body |> Lwt_main.run in let contains haystack needle = let nl = String.length needle and hl = String.length haystack in let rec at i = i + nl <= hl && (String.sub haystack i nl = needle || at (i + 1)) in at 0 in (* Twice, so the second read comes from the cache. *) Alcotest.(check bool) "first read" true (contains (body "/project/raw/f.txt") "generation-one"); Alcotest.(check bool) "cached read" true (contains (body "/project/raw/f.txt") "generation-one"); write "generation-two\n"; commit "second"; Alcotest.(check bool) "new commit visible" true (contains (body "/project/raw/f.txt") "generation-two")) let suite = ( "router", [ Alcotest.test_case "invalid repo name" `Slow test_invalid_repo_name; Alcotest.test_case "missing repo" `Slow test_missing_repo; Alcotest.test_case "invalid hash" `Slow test_invalid_hash; Alcotest.test_case "missing object" `Slow test_missing_object; Alcotest.test_case "raw response headers" `Slow test_raw_response_headers; Alcotest.test_case "file by path" `Slow test_file_by_path; Alcotest.test_case "cache sees new commit" `Slow test_cache_sees_new_commit; ] )