OAuth 2.0 authorization and token exchange
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

oauth: split test.ml into per-module test files

Breaks the single test.ml runner into one test_<module>.ml per subject:

- test_authorization_url.ml
- test_client_auth.ml
- test_helpers.ml
- test_par.ml
- test_parse_token_response.ml
- test_provider.ml
- test_redirect_uri.ml

Landed here via git-x commit split from an accidentally-bundled commit
in another session's staging; original intent preserved.

+556
+19
test/test_authorization_url.ml
··· 1 + open Test_helpers 2 + 3 + let test_includes_response_type_code () = 4 + let url = 5 + Oauth.authorization_url Oauth.Github ~client_id:"test_client" 6 + ~redirect_uri:(redir "https://example.com/callback") 7 + ~state:"test_state" ~scope:[ "repo" ] () 8 + in 9 + let uri = Uri.of_string url in 10 + Alcotest.(check (option string)) 11 + "response_type=code" (Some "code") 12 + (Uri.get_query_param uri "response_type") 13 + 14 + let suite = 15 + ( "authorization_url", 16 + [ 17 + Alcotest.test_case "includes response_type=code" `Quick 18 + test_includes_response_type_code; 19 + ] )
+76
test/test_client_auth.ml
··· 1 + let test_none_apply () = 2 + let a = Oauth.Client_auth.none ~client_id:"cid" in 3 + let fields, headers = Oauth.Client_auth.apply a in 4 + Alcotest.(check (list (pair string string))) 5 + "fields" 6 + [ ("client_id", "cid") ] 7 + fields; 8 + Alcotest.(check (list (pair string string))) "no headers" [] headers 9 + 10 + let test_post_apply () = 11 + let a = 12 + Oauth.Client_auth.post ~client_id:"cid" ~client_secret:"supersecret" 13 + in 14 + let fields, headers = Oauth.Client_auth.apply a in 15 + Alcotest.(check (list (pair string string))) 16 + "fields" 17 + [ ("client_id", "cid"); ("client_secret", "supersecret") ] 18 + fields; 19 + Alcotest.(check (list (pair string string))) "no headers" [] headers 20 + 21 + let test_basic_apply () = 22 + let a = Oauth.Client_auth.basic ~client_id:"cid" ~client_secret:"csec" in 23 + let fields, headers = Oauth.Client_auth.apply a in 24 + Alcotest.(check (list (pair string string))) 25 + "fields" 26 + [ ("client_id", "cid") ] 27 + fields; 28 + (* Basic base64("cid:csec") = Basic Y2lkOmNzZWM= *) 29 + Alcotest.(check (list (pair string string))) 30 + "Authorization header" 31 + [ ("Authorization", "Basic Y2lkOmNzZWM=") ] 32 + headers 33 + 34 + let test_basic_percent_encodes_special_chars () = 35 + (* RFC 6749 §2.3.1: credentials are form-urlencoded before joining with ":" 36 + so a secret containing ':' or other special chars does not produce an 37 + ambiguous token. We percent-encode both halves uniformly. *) 38 + let a = 39 + Oauth.Client_auth.basic ~client_id:"id:with:colons" 40 + ~client_secret:"p@ss:wor d" 41 + in 42 + let _, headers = Oauth.Client_auth.apply a in 43 + let auth = List.assoc "Authorization" headers in 44 + let b64 = 45 + match String.split_on_char ' ' auth with 46 + | [ "Basic"; b64 ] -> b64 47 + | _ -> Alcotest.failf "malformed Authorization header: %s" auth 48 + in 49 + let decoded = Base64.decode_exn b64 in 50 + Alcotest.(check string) 51 + "percent-encoded halves joined by ':'" "id%3Awith%3Acolons:p%40ss%3Awor%20d" 52 + decoded 53 + 54 + let test_client_id_accessor () = 55 + Alcotest.(check string) 56 + "none" "a" 57 + (Oauth.Client_auth.client_id (Oauth.Client_auth.none ~client_id:"a")); 58 + Alcotest.(check string) 59 + "post" "b" 60 + (Oauth.Client_auth.client_id 61 + (Oauth.Client_auth.post ~client_id:"b" ~client_secret:"x")); 62 + Alcotest.(check string) 63 + "basic" "c" 64 + (Oauth.Client_auth.client_id 65 + (Oauth.Client_auth.basic ~client_id:"c" ~client_secret:"x")) 66 + 67 + let suite = 68 + ( "client_auth", 69 + [ 70 + Alcotest.test_case "none apply" `Quick test_none_apply; 71 + Alcotest.test_case "post apply" `Quick test_post_apply; 72 + Alcotest.test_case "basic apply" `Quick test_basic_apply; 73 + Alcotest.test_case "basic percent-encodes special chars" `Quick 74 + test_basic_percent_encodes_special_chars; 75 + Alcotest.test_case "client_id accessor" `Quick test_client_id_accessor; 76 + ] )
+32
test/test_helpers.ml
··· 1 + let redir s = Oauth.redirect_uri s |> Result.get_ok 2 + 3 + let contains str ~substring = 4 + let len = String.length substring in 5 + let rec go i = 6 + if i + len > String.length str then false 7 + else if String.sub str i len = substring then true 8 + else go (i + 1) 9 + in 10 + go 0 11 + 12 + let first_existing paths = 13 + match List.find_opt Sys.file_exists paths with 14 + | Some path -> path 15 + | None -> 16 + Alcotest.fail 17 + (Fmt.str "missing test fixture, looked for one of: %s" 18 + (String.concat ", " paths)) 19 + 20 + let read_file paths = 21 + In_channel.with_open_bin (first_existing paths) In_channel.input_all 22 + 23 + let parse_token_error = Alcotest.testable Oauth.pp_parse_token_error ( = ) 24 + 25 + let custom name = 26 + match 27 + Oauth.custom_provider ~name ~authorize_url:"https://example.com/auth" 28 + ~token_url:"https://example.com/token" 29 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" () 30 + with 31 + | Ok p -> Oauth.Custom p 32 + | Error (`Msg msg) -> failwith msg
+118
test/test_par.ml
··· 1 + open Test_helpers 2 + 3 + let par_error = Alcotest.testable Oauth.Par.pp_error ( = ) 4 + 5 + let test_parse_response_ok () = 6 + let body = 7 + {|{"request_uri":"urn:ietf:params:oauth:request_uri:x","expires_in":60}|} 8 + in 9 + match Oauth.Par.parse_response body with 10 + | Ok r -> 11 + Alcotest.(check string) 12 + "request_uri" "urn:ietf:params:oauth:request_uri:x" r.request_uri; 13 + Alcotest.(check int) "expires_in" 60 r.expires_in 14 + | Error e -> Alcotest.failf "unexpected: %a" Oauth.Par.pp_error e 15 + 16 + let test_parse_response_missing_request_uri () = 17 + let body = {|{"expires_in":60}|} in 18 + Alcotest.(check (result reject par_error)) 19 + "missing request_uri" (Error Oauth.Par.Missing_request_uri) 20 + (Oauth.Par.parse_response body) 21 + 22 + let test_parse_response_missing_expires_in () = 23 + let body = {|{"request_uri":"urn:x"}|} in 24 + Alcotest.(check (result reject par_error)) 25 + "missing expires_in" (Error Oauth.Par.Invalid_expires_in) 26 + (Oauth.Par.parse_response body) 27 + 28 + let test_parse_response_invalid_json () = 29 + Alcotest.(check (result reject par_error)) 30 + "invalid json" (Error Oauth.Par.Invalid_json) 31 + (Oauth.Par.parse_response "not json") 32 + 33 + let test_push_requires_par_endpoint () = 34 + (* Built-in providers have no PAR endpoint; push should refuse cleanly. *) 35 + Eio_main.run @@ fun env -> 36 + Eio.Switch.run @@ fun sw -> 37 + let http = Requests.v ~sw env in 38 + Alcotest.(check (result reject par_error)) 39 + "no par_endpoint" (Error Oauth.Par.No_par_endpoint) 40 + (Oauth.Par.push http Oauth.Github 41 + ~client_auth:(Oauth.Client_auth.post ~client_id:"x" ~client_secret:"y") 42 + ~redirect_uri:(redir "https://example.com/cb") 43 + ~state:"s" ~scope:[ "r" ] ()) 44 + 45 + let test_authorization_url_only_carries_client_id_and_request_uri () = 46 + match 47 + Oauth.custom_provider ~name:"atp" ~authorize_url:"https://as.example/auth" 48 + ~token_url:"https://as.example/token" 49 + ~userinfo_url:"https://as.example/user" ~uid_field:"sub" 50 + ~par_endpoint:"https://as.example/par" () 51 + with 52 + | Error (`Msg msg) -> Alcotest.failf "custom_provider: %s" msg 53 + | Ok c -> 54 + let url = 55 + Oauth.Par.authorization_url (Oauth.Custom c) ~client_id:"cid" 56 + ~request_uri:"urn:example:req:1" 57 + in 58 + let uri = Uri.of_string url in 59 + Alcotest.(check (option string)) 60 + "client_id" (Some "cid") 61 + (Uri.get_query_param uri "client_id"); 62 + Alcotest.(check (option string)) 63 + "request_uri" (Some "urn:example:req:1") 64 + (Uri.get_query_param uri "request_uri"); 65 + Alcotest.(check (option string)) 66 + "no response_type" None 67 + (Uri.get_query_param uri "response_type"); 68 + Alcotest.(check (option string)) 69 + "no scope" None 70 + (Uri.get_query_param uri "scope"); 71 + Alcotest.(check (option string)) 72 + "no state" None 73 + (Uri.get_query_param uri "state") 74 + 75 + let test_custom_provider_accepts_par_endpoint () = 76 + match 77 + Oauth.custom_provider ~name:"atp" ~authorize_url:"https://as.example/auth" 78 + ~token_url:"https://as.example/token" 79 + ~userinfo_url:"https://as.example/user" ~uid_field:"sub" 80 + ~par_endpoint:"https://as.example/par" () 81 + with 82 + | Ok c -> 83 + Alcotest.(check (option string)) 84 + "par_endpoint" (Some "https://as.example/par") c.par_endpoint 85 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 86 + 87 + let test_custom_provider_rejects_http_par_endpoint () = 88 + match 89 + Oauth.custom_provider ~name:"atp" ~authorize_url:"https://as.example/auth" 90 + ~token_url:"https://as.example/token" 91 + ~userinfo_url:"https://as.example/user" ~uid_field:"sub" 92 + ~par_endpoint:"http://as.example/par" () 93 + with 94 + | Error (`Msg msg) -> 95 + Alcotest.(check bool) 96 + "mentions par_endpoint" true 97 + (contains msg ~substring:"par_endpoint") 98 + | Ok _ -> Alcotest.fail "expected Error for http:// par_endpoint" 99 + 100 + let suite = 101 + ( "par", 102 + [ 103 + Alcotest.test_case "parse_response ok" `Quick test_parse_response_ok; 104 + Alcotest.test_case "parse_response missing request_uri" `Quick 105 + test_parse_response_missing_request_uri; 106 + Alcotest.test_case "parse_response missing expires_in" `Quick 107 + test_parse_response_missing_expires_in; 108 + Alcotest.test_case "parse_response invalid json" `Quick 109 + test_parse_response_invalid_json; 110 + Alcotest.test_case "push refuses when provider lacks endpoint" `Quick 111 + test_push_requires_par_endpoint; 112 + Alcotest.test_case "authorization_url carries only client_id+request_uri" 113 + `Quick test_authorization_url_only_carries_client_id_and_request_uri; 114 + Alcotest.test_case "custom_provider accepts par_endpoint" `Quick 115 + test_custom_provider_accepts_par_endpoint; 116 + Alcotest.test_case "custom_provider rejects http:// par_endpoint" `Quick 117 + test_custom_provider_rejects_http_par_endpoint; 118 + ] )
+116
test/test_parse_token_response.ml
··· 1 + open Test_helpers 2 + 3 + let test_ok () = 4 + let body = 5 + {|{"access_token":"gho_abc","expires_in":3600,"refresh_token":"ghr_xyz"}|} 6 + in 7 + match Oauth.parse_token_response body with 8 + | Ok t -> 9 + Alcotest.(check string) "access_token" "gho_abc" t.access_token; 10 + Alcotest.(check (option int)) "expires_in" (Some 3600) t.expires_in; 11 + Alcotest.(check (option string)) 12 + "refresh_token" (Some "ghr_xyz") t.refresh_token 13 + | Error e -> 14 + Alcotest.failf "expected Ok, got Error %a" Oauth.pp_parse_token_error e 15 + 16 + let test_invalid_json () = 17 + let body = "not json at all" in 18 + Alcotest.(check (result reject parse_token_error)) 19 + "invalid json" (Error Oauth.Invalid_json) 20 + (Oauth.parse_token_response body) 21 + 22 + let test_missing_access_token () = 23 + let body = {|{"expires_in":3600}|} in 24 + Alcotest.(check (result reject parse_token_error)) 25 + "missing access_token" (Error Oauth.Missing_access_token) 26 + (Oauth.parse_token_response body) 27 + 28 + let test_empty_access_token () = 29 + let body = {|{"access_token":""}|} in 30 + Alcotest.(check (result reject parse_token_error)) 31 + "empty access_token" (Error Oauth.Missing_access_token) 32 + (Oauth.parse_token_response body) 33 + 34 + let test_invalid_format () = 35 + let body = {|{"access_token":12345}|} in 36 + Alcotest.(check (result reject parse_token_error)) 37 + "access_token wrong type" (Error Oauth.Invalid_token_format) 38 + (Oauth.parse_token_response body) 39 + 40 + let test_rejects_mac_token () = 41 + let body = 42 + {|{"access_token":"tok_abc","token_type":"mac","expires_in":3600}|} 43 + in 44 + match Oauth.parse_token_response body with 45 + | Error (Oauth.Unsupported_token_type "mac") -> () 46 + | Error e -> Alcotest.failf "wrong error: %a" Oauth.pp_parse_token_error e 47 + | Ok _ -> Alcotest.fail "expected rejection of mac token_type" 48 + 49 + let test_accepts_bearer () = 50 + let body = 51 + {|{"access_token":"tok_abc","token_type":"Bearer","expires_in":3600}|} 52 + in 53 + match Oauth.parse_token_response body with 54 + | Ok t -> Alcotest.(check string) "access_token" "tok_abc" t.access_token 55 + | Error e -> 56 + Alcotest.failf "unexpected error: %a" Oauth.pp_parse_token_error e 57 + 58 + let test_accepts_bearer_case_insensitive () = 59 + let body = {|{"access_token":"tok_abc","token_type":"BEARER"}|} in 60 + match Oauth.parse_token_response body with 61 + | Ok t -> Alcotest.(check string) "access_token" "tok_abc" t.access_token 62 + | Error e -> 63 + Alcotest.failf "unexpected error: %a" Oauth.pp_parse_token_error e 64 + 65 + let test_accepts_missing_token_type () = 66 + (* GitHub omits token_type *) 67 + let body = {|{"access_token":"gho_abc"}|} in 68 + match Oauth.parse_token_response body with 69 + | Ok t -> Alcotest.(check string) "access_token" "gho_abc" t.access_token 70 + | Error e -> 71 + Alcotest.failf "unexpected error: %a" Oauth.pp_parse_token_error e 72 + 73 + (* -- TLS enforcement on exchange_code / verify_tls getter ----------- *) 74 + 75 + let test_exchange_code_rejects_verify_tls_false () = 76 + Eio_main.run @@ fun env -> 77 + Eio.Switch.run @@ fun sw -> 78 + let http = Requests.v ~sw ~verify_tls:false env in 79 + let raised = ref false in 80 + (try 81 + ignore 82 + (Oauth.exchange_code http Oauth.Github 83 + ~client_auth: 84 + (Oauth.Client_auth.post ~client_id:"x" ~client_secret:"y") 85 + ~code:"z" 86 + ~redirect_uri:(redir "https://example.com/cb") 87 + ()) 88 + with Invalid_argument _ -> raised := true); 89 + Alcotest.(check bool) "raises Invalid_argument" true !raised 90 + 91 + let test_verify_tls_getter () = 92 + Eio_main.run @@ fun env -> 93 + Eio.Switch.run @@ fun sw -> 94 + let secure = Requests.v ~sw env in 95 + let insecure = Requests.v ~sw ~verify_tls:false env in 96 + Alcotest.(check bool) "default is true" true (Requests.verify_tls secure); 97 + Alcotest.(check bool) "false when set" false (Requests.verify_tls insecure) 98 + 99 + let suite = 100 + ( "parse_token_response", 101 + [ 102 + Alcotest.test_case "ok" `Quick test_ok; 103 + Alcotest.test_case "invalid json" `Quick test_invalid_json; 104 + Alcotest.test_case "missing access_token" `Quick test_missing_access_token; 105 + Alcotest.test_case "empty access_token" `Quick test_empty_access_token; 106 + Alcotest.test_case "invalid format" `Quick test_invalid_format; 107 + Alcotest.test_case "rejects mac" `Quick test_rejects_mac_token; 108 + Alcotest.test_case "accepts bearer" `Quick test_accepts_bearer; 109 + Alcotest.test_case "bearer case-insensitive" `Quick 110 + test_accepts_bearer_case_insensitive; 111 + Alcotest.test_case "accepts missing token_type" `Quick 112 + test_accepts_missing_token_type; 113 + Alcotest.test_case "exchange_code rejects verify_tls:false" `Quick 114 + test_exchange_code_rejects_verify_tls_false; 115 + Alcotest.test_case "verify_tls getter" `Quick test_verify_tls_getter; 116 + ] )
+140
test/test_provider.ml
··· 1 + open Test_helpers 2 + 3 + (* -- provider_name / provider_slug ------------------------------------ *) 4 + 5 + let test_name_is_raw () = 6 + Alcotest.(check string) "builtin" "github" (Oauth.provider_name Oauth.Github); 7 + Alcotest.(check string) 8 + "raw name" "corp/sso" 9 + (Oauth.provider_name (custom "corp/sso")); 10 + Alcotest.(check string) 11 + "preserves case" "Acme SSO" 12 + (Oauth.provider_name (custom "Acme SSO")); 13 + Alcotest.(check string) 14 + "unicode preserved" "\xe4\xbc\x81\xe6\xa5\xad" 15 + (Oauth.provider_name (custom "\xe4\xbc\x81\xe6\xa5\xad")) 16 + 17 + let test_slug_is_path_safe () = 18 + Alcotest.(check string) "builtin" "github" (Oauth.provider_slug Oauth.Github); 19 + Alcotest.(check string) 20 + "slash" "corp-sso" 21 + (Oauth.provider_slug (custom "corp/sso")); 22 + Alcotest.(check string) 23 + "spaces" "acme-sso" 24 + (Oauth.provider_slug (custom "Acme SSO")); 25 + Alcotest.(check string) 26 + "already clean" "myidp" 27 + (Oauth.provider_slug (custom "myidp")); 28 + Alcotest.(check string) 29 + "special chars" "my-cool-provider" 30 + (Oauth.provider_slug (custom "My Cool_Provider!")); 31 + Alcotest.(check string) 32 + "non-ascii fallback" "custom" 33 + (Oauth.provider_slug (custom "\xe4\xbc\x81\xe6\xa5\xad")) 34 + 35 + (* -- custom_provider URL validation ----------------------------------- *) 36 + 37 + let test_rejects_http_token_url () = 38 + match 39 + Oauth.custom_provider ~name:"bad" ~authorize_url:"https://example.com/auth" 40 + ~token_url:"http://example.com/token" 41 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" () 42 + with 43 + | Error (`Msg msg) -> 44 + Alcotest.(check bool) 45 + "mentions token_url" true 46 + (contains msg ~substring:"token_url") 47 + | Ok _ -> Alcotest.fail "expected Error for http:// token_url" 48 + 49 + let test_rejects_http_authorize_url () = 50 + match 51 + Oauth.custom_provider ~name:"bad" ~authorize_url:"http://example.com/auth" 52 + ~token_url:"https://example.com/token" 53 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" () 54 + with 55 + | Error (`Msg msg) -> 56 + Alcotest.(check bool) 57 + "mentions authorize_url" true 58 + (contains msg ~substring:"authorize_url") 59 + | Ok _ -> Alcotest.fail "expected Error for http:// authorize_url" 60 + 61 + let test_rejects_http_userinfo_url () = 62 + match 63 + Oauth.custom_provider ~name:"bad" ~authorize_url:"https://example.com/auth" 64 + ~token_url:"https://example.com/token" 65 + ~userinfo_url:"http://example.com/user" ~uid_field:"id" () 66 + with 67 + | Error (`Msg msg) -> 68 + Alcotest.(check bool) 69 + "mentions userinfo_url" true 70 + (contains msg ~substring:"userinfo_url") 71 + | Ok _ -> Alcotest.fail "expected Error for http:// userinfo_url" 72 + 73 + let test_accepts_https () = 74 + match 75 + Oauth.custom_provider ~name:"good" ~authorize_url:"https://example.com/auth" 76 + ~token_url:"https://example.com/token" 77 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" () 78 + with 79 + | Ok p -> Alcotest.(check string) "name" "good" p.name 80 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 81 + 82 + (* -- custom_provider slug collision ----------------------------------- *) 83 + 84 + let test_rejects_github_slug_collision () = 85 + (* "Git Hub" slugifies to "git-hub", not "github", so it should be OK *) 86 + (* But "GitHub" slugifies to "github" — collision with built-in *) 87 + match 88 + Oauth.custom_provider ~name:"GitHub" ~authorize_url:"https://evil.com/auth" 89 + ~token_url:"https://evil.com/token" ~userinfo_url:"https://evil.com/user" 90 + ~uid_field:"id" () 91 + with 92 + | Error (`Msg msg) -> 93 + Alcotest.(check bool) 94 + "mentions collision" true 95 + (contains msg ~substring:"collides") 96 + | Ok _ -> Alcotest.fail "expected Error for slug colliding with built-in" 97 + 98 + let test_rejects_google_slug_collision () = 99 + match 100 + Oauth.custom_provider ~name:"Google" ~authorize_url:"https://evil.com/auth" 101 + ~token_url:"https://evil.com/token" ~userinfo_url:"https://evil.com/user" 102 + ~uid_field:"id" () 103 + with 104 + | Error (`Msg msg) -> 105 + Alcotest.(check bool) 106 + "mentions collision" true 107 + (contains msg ~substring:"collides") 108 + | Ok _ -> Alcotest.fail "expected Error for slug colliding with built-in" 109 + 110 + let test_allows_non_colliding_slug () = 111 + match 112 + Oauth.custom_provider ~name:"My Corp SSO" 113 + ~authorize_url:"https://sso.corp.com/auth" 114 + ~token_url:"https://sso.corp.com/token" 115 + ~userinfo_url:"https://sso.corp.com/user" ~uid_field:"sub" () 116 + with 117 + | Ok p -> Alcotest.(check string) "name" "My Corp SSO" p.name 118 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 119 + 120 + let suite = 121 + ( "provider", 122 + [ 123 + Alcotest.test_case "provider_name is raw" `Quick test_name_is_raw; 124 + Alcotest.test_case "provider_slug is path-safe" `Quick 125 + test_slug_is_path_safe; 126 + Alcotest.test_case "custom_provider rejects http:// token_url" `Quick 127 + test_rejects_http_token_url; 128 + Alcotest.test_case "custom_provider rejects http:// authorize_url" `Quick 129 + test_rejects_http_authorize_url; 130 + Alcotest.test_case "custom_provider rejects http:// userinfo_url" `Quick 131 + test_rejects_http_userinfo_url; 132 + Alcotest.test_case "custom_provider accepts https://" `Quick 133 + test_accepts_https; 134 + Alcotest.test_case "custom_provider rejects github slug collision" `Quick 135 + test_rejects_github_slug_collision; 136 + Alcotest.test_case "custom_provider rejects google slug collision" `Quick 137 + test_rejects_google_slug_collision; 138 + Alcotest.test_case "custom_provider allows non-colliding slug" `Quick 139 + test_allows_non_colliding_slug; 140 + ] )
+55
test/test_redirect_uri.ml
··· 1 + open Test_helpers 2 + 3 + let test_rejects_http () = 4 + match Oauth.redirect_uri "http://example.com/callback" with 5 + | Error (`Msg msg) -> 6 + Alcotest.(check bool) 7 + "mentions HTTPS" true 8 + (contains msg ~substring:"HTTPS") 9 + | Ok _ -> Alcotest.fail "expected Error for http:// redirect_uri" 10 + 11 + let test_accepts_https () = 12 + match Oauth.redirect_uri "https://example.com/callback" with 13 + | Ok _ -> () 14 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 15 + 16 + let test_allows_localhost_http () = 17 + match Oauth.redirect_uri "http://localhost:8080/callback" with 18 + | Ok _ -> () 19 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 20 + 21 + let test_allows_127_http () = 22 + match Oauth.redirect_uri "http://127.0.0.1:3000/callback" with 23 + | Ok _ -> () 24 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 25 + 26 + let test_allows_ipv6_loopback () = 27 + match Oauth.redirect_uri "http://[::1]:8080/callback" with 28 + | Ok _ -> () 29 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 30 + 31 + let test_rejects_fragment () = 32 + match Oauth.redirect_uri "https://example.com/callback#frag" with 33 + | Error (`Msg msg) -> 34 + Alcotest.(check bool) 35 + "mentions fragment" true 36 + (contains msg ~substring:"fragment") 37 + | Ok _ -> Alcotest.fail "expected Error for URI with fragment" 38 + 39 + let test_rejects_no_scheme () = 40 + match Oauth.redirect_uri "/callback" with 41 + | Error _ -> () 42 + | Ok _ -> Alcotest.fail "expected Error for relative URI" 43 + 44 + let suite = 45 + ( "redirect_uri", 46 + [ 47 + Alcotest.test_case "rejects http://" `Quick test_rejects_http; 48 + Alcotest.test_case "accepts https://" `Quick test_accepts_https; 49 + Alcotest.test_case "allows http://localhost" `Quick 50 + test_allows_localhost_http; 51 + Alcotest.test_case "allows http://127.0.0.1" `Quick test_allows_127_http; 52 + Alcotest.test_case "allows http://[::1]" `Quick test_allows_ipv6_loopback; 53 + Alcotest.test_case "rejects fragment" `Quick test_rejects_fragment; 54 + Alcotest.test_case "rejects no scheme" `Quick test_rejects_no_scheme; 55 + ] )