(* Tests for Github_oauth *) (* String helper *) let is_substring str ~substring = let len = String.length substring in let rec check i = if i + len > String.length str then false else if String.sub str i len = substring then true else check (i + 1) in check 0 let test_generate_state () = let state = Github_oauth.generate_state () in Alcotest.(check int) "state length is 64" 64 (String.length state); (* Check it's all hex characters *) String.iter (fun c -> let is_hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') in Alcotest.(check bool) "is hex char" true is_hex) state let test_generate_state_unique () = let state1 = Github_oauth.generate_state () in let state2 = Github_oauth.generate_state () in Alcotest.(check bool) "states are different" true (state1 <> state2) let test_authorization_url_basic () = let url = Github_oauth.authorization_url ~client_id:"test_client" ~callback_url:"https://example.com/callback" ~state:"test_state" ~scope:[ "repo" ] in Alcotest.(check bool) "contains github.com" true (String.sub url 0 24 = "https://github.com/login"); Alcotest.(check bool) "contains client_id" true (is_substring url ~substring:"client_id=test_client"); Alcotest.(check bool) "contains state" true (is_substring url ~substring:"state=test_state"); Alcotest.(check bool) "contains scope" true (is_substring url ~substring:"scope=repo") let test_authorization_url_no_scope () = let url = Github_oauth.authorization_url ~client_id:"test_client" ~callback_url:"https://example.com/callback" ~state:"test_state" ~scope:[] in Alcotest.(check bool) "no scope param" true (not (is_substring url ~substring:"scope=")) let test_authorization_url_multiple_scopes () = let url = Github_oauth.authorization_url ~client_id:"test_client" ~callback_url:"https://example.com/callback" ~state:"test_state" ~scope:[ "repo"; "user"; "read:org" ] in (* Scopes are space-separated, URL-encoded *) Alcotest.(check bool) "contains scope" true (is_substring url ~substring:"scope=") let test_exchange_request_body () = let body = Github_oauth.exchange_request_body ~client_id:"test_client" ~client_secret:"test_secret" ~code:"auth_code" ~redirect_uri:"https://example.com/callback" in Alcotest.(check bool) "contains client_id" true (is_substring body ~substring:"\"client_id\""); Alcotest.(check bool) "contains client_secret" true (is_substring body ~substring:"\"client_secret\""); Alcotest.(check bool) "contains code" true (is_substring body ~substring:"\"code\""); Alcotest.(check bool) "contains redirect_uri" true (is_substring body ~substring:"\"redirect_uri\"") let test_parse_token_oauth_app () = let json = {|{"access_token":"gho_abc123"}|} in match Github_oauth.parse_token_response json with | Ok t -> Alcotest.(check string) "access_token" "gho_abc123" t.access_token; Alcotest.(check (option int)) "no expires_in" None t.expires_in; Alcotest.(check (option string)) "no refresh_token" None t.refresh_token | Error e -> Alcotest.fail (Fmt.str "parse failed: %a" Github_oauth.pp_parse_token_error e) let test_parse_token_github_app () = let json = {|{"access_token":"ghu_abc123","expires_in":28800,"refresh_token":"ghr_xyz789","refresh_token_expires_in":15897600}|} in match Github_oauth.parse_token_response json with | Ok t -> Alcotest.(check string) "access_token" "ghu_abc123" t.access_token; Alcotest.(check (option int)) "expires_in" (Some 28800) t.expires_in; Alcotest.(check (option string)) "refresh_token" (Some "ghr_xyz789") t.refresh_token; Alcotest.(check (option int)) "refresh_token_expires_in" (Some 15897600) t.refresh_token_expires_in | Error e -> Alcotest.fail (Fmt.str "parse failed: %a" Github_oauth.pp_parse_token_error e) let test_parse_token_extra_fields () = (* GitHub may add extra fields - should be ignored *) let json = {|{"access_token":"gho_test","token_type":"bearer","scope":"repo"}|} in match Github_oauth.parse_token_response json with | Ok t -> Alcotest.(check string) "access_token" "gho_test" t.access_token | Error e -> Alcotest.fail (Fmt.str "parse failed: %a" Github_oauth.pp_parse_token_error e) let test_parse_token_invalid_json () = let json = "not json" in match Github_oauth.parse_token_response json with | Ok _ -> Alcotest.fail "should have failed" | Error e -> Alcotest.(check bool) "is Invalid_json" true (e = Github_oauth.Invalid_json) let test_refresh_request_body () = let body = Github_oauth.refresh_request_body ~client_id:"test_client" ~client_secret:"test_secret" ~refresh_token:"ghr_abc123" in Alcotest.(check bool) "contains client_id" true (is_substring body ~substring:"\"client_id\""); Alcotest.(check bool) "contains grant_type" true (is_substring body ~substring:"\"grant_type\""); Alcotest.(check bool) "contains refresh_token" true (is_substring body ~substring:"\"refresh_token\"") let suite = ( "github_oauth", [ Alcotest.test_case "generate_state length and format" `Quick test_generate_state; Alcotest.test_case "generate_state unique" `Quick test_generate_state_unique; Alcotest.test_case "authorization_url basic" `Quick test_authorization_url_basic; Alcotest.test_case "authorization_url no scope" `Quick test_authorization_url_no_scope; Alcotest.test_case "authorization_url multiple scopes" `Quick test_authorization_url_multiple_scopes; Alcotest.test_case "exchange request body" `Quick test_exchange_request_body; Alcotest.test_case "parse_token OAuth App" `Quick test_parse_token_oauth_app; Alcotest.test_case "parse_token GitHub App" `Quick test_parse_token_github_app; Alcotest.test_case "parse_token extra fields" `Quick test_parse_token_extra_fields; Alcotest.test_case "parse_token invalid json" `Quick test_parse_token_invalid_json; Alcotest.test_case "refresh request body" `Quick test_refresh_request_body; ] )