GitHub OAuth helpers
1(* Tests for Github_oauth *)
2
3(* String helper *)
4let is_substring str ~substring =
5 let len = String.length substring in
6 let rec check i =
7 if i + len > String.length str then false
8 else if String.sub str i len = substring then true
9 else check (i + 1)
10 in
11 check 0
12
13let test_generate_state () =
14 let state = Github_oauth.generate_state () in
15 Alcotest.(check int) "state length is 64" 64 (String.length state);
16 (* Check it's all hex characters *)
17 String.iter
18 (fun c ->
19 let is_hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') in
20 Alcotest.(check bool) "is hex char" true is_hex)
21 state
22
23let test_generate_state_unique () =
24 let state1 = Github_oauth.generate_state () in
25 let state2 = Github_oauth.generate_state () in
26 Alcotest.(check bool) "states are different" true (state1 <> state2)
27
28let test_authorization_url_basic () =
29 let url =
30 Github_oauth.authorization_url ~client_id:"test_client"
31 ~callback_url:"https://example.com/callback" ~state:"test_state"
32 ~scope:[ "repo" ]
33 in
34 Alcotest.(check bool)
35 "contains github.com" true
36 (String.sub url 0 24 = "https://github.com/login");
37 Alcotest.(check bool)
38 "contains client_id" true
39 (is_substring url ~substring:"client_id=test_client");
40 Alcotest.(check bool)
41 "contains state" true
42 (is_substring url ~substring:"state=test_state");
43 Alcotest.(check bool)
44 "contains scope" true
45 (is_substring url ~substring:"scope=repo")
46
47let test_authorization_url_no_scope () =
48 let url =
49 Github_oauth.authorization_url ~client_id:"test_client"
50 ~callback_url:"https://example.com/callback" ~state:"test_state" ~scope:[]
51 in
52 Alcotest.(check bool)
53 "no scope param" true
54 (not (is_substring url ~substring:"scope="))
55
56let test_authorization_url_multiple_scopes () =
57 let url =
58 Github_oauth.authorization_url ~client_id:"test_client"
59 ~callback_url:"https://example.com/callback" ~state:"test_state"
60 ~scope:[ "repo"; "user"; "read:org" ]
61 in
62 (* Scopes are space-separated, URL-encoded *)
63 Alcotest.(check bool)
64 "contains scope" true
65 (is_substring url ~substring:"scope=")
66
67let test_exchange_request_body () =
68 let body =
69 Github_oauth.exchange_request_body ~client_id:"test_client"
70 ~client_secret:"test_secret" ~code:"auth_code"
71 ~redirect_uri:"https://example.com/callback"
72 in
73 Alcotest.(check bool)
74 "contains client_id" true
75 (is_substring body ~substring:"\"client_id\"");
76 Alcotest.(check bool)
77 "contains client_secret" true
78 (is_substring body ~substring:"\"client_secret\"");
79 Alcotest.(check bool)
80 "contains code" true
81 (is_substring body ~substring:"\"code\"");
82 Alcotest.(check bool)
83 "contains redirect_uri" true
84 (is_substring body ~substring:"\"redirect_uri\"")
85
86let test_parse_token_oauth_app () =
87 let json = {|{"access_token":"gho_abc123"}|} in
88 match Github_oauth.parse_token_response json with
89 | Ok t ->
90 Alcotest.(check string) "access_token" "gho_abc123" t.access_token;
91 Alcotest.(check (option int)) "no expires_in" None t.expires_in;
92 Alcotest.(check (option string)) "no refresh_token" None t.refresh_token
93 | Error e ->
94 Alcotest.fail
95 (Fmt.str "parse failed: %a" Github_oauth.pp_parse_token_error e)
96
97let test_parse_token_github_app () =
98 let json =
99 {|{"access_token":"ghu_abc123","expires_in":28800,"refresh_token":"ghr_xyz789","refresh_token_expires_in":15897600}|}
100 in
101 match Github_oauth.parse_token_response json with
102 | Ok t ->
103 Alcotest.(check string) "access_token" "ghu_abc123" t.access_token;
104 Alcotest.(check (option int)) "expires_in" (Some 28800) t.expires_in;
105 Alcotest.(check (option string))
106 "refresh_token" (Some "ghr_xyz789") t.refresh_token;
107 Alcotest.(check (option int))
108 "refresh_token_expires_in" (Some 15897600) t.refresh_token_expires_in
109 | Error e ->
110 Alcotest.fail
111 (Fmt.str "parse failed: %a" Github_oauth.pp_parse_token_error e)
112
113let test_parse_token_extra_fields () =
114 (* GitHub may add extra fields - should be ignored *)
115 let json =
116 {|{"access_token":"gho_test","token_type":"bearer","scope":"repo"}|}
117 in
118 match Github_oauth.parse_token_response json with
119 | Ok t -> Alcotest.(check string) "access_token" "gho_test" t.access_token
120 | Error e ->
121 Alcotest.fail
122 (Fmt.str "parse failed: %a" Github_oauth.pp_parse_token_error e)
123
124let test_parse_token_invalid_json () =
125 let json = "not json" in
126 match Github_oauth.parse_token_response json with
127 | Ok _ -> Alcotest.fail "should have failed"
128 | Error e ->
129 Alcotest.(check bool)
130 "is Invalid_json" true
131 (e = Github_oauth.Invalid_json)
132
133let test_refresh_request_body () =
134 let body =
135 Github_oauth.refresh_request_body ~client_id:"test_client"
136 ~client_secret:"test_secret" ~refresh_token:"ghr_abc123"
137 in
138 Alcotest.(check bool)
139 "contains client_id" true
140 (is_substring body ~substring:"\"client_id\"");
141 Alcotest.(check bool)
142 "contains grant_type" true
143 (is_substring body ~substring:"\"grant_type\"");
144 Alcotest.(check bool)
145 "contains refresh_token" true
146 (is_substring body ~substring:"\"refresh_token\"")
147
148let suite =
149 ( "github_oauth",
150 [
151 Alcotest.test_case "generate_state length and format" `Quick
152 test_generate_state;
153 Alcotest.test_case "generate_state unique" `Quick
154 test_generate_state_unique;
155 Alcotest.test_case "authorization_url basic" `Quick
156 test_authorization_url_basic;
157 Alcotest.test_case "authorization_url no scope" `Quick
158 test_authorization_url_no_scope;
159 Alcotest.test_case "authorization_url multiple scopes" `Quick
160 test_authorization_url_multiple_scopes;
161 Alcotest.test_case "exchange request body" `Quick
162 test_exchange_request_body;
163 Alcotest.test_case "parse_token OAuth App" `Quick
164 test_parse_token_oauth_app;
165 Alcotest.test_case "parse_token GitHub App" `Quick
166 test_parse_token_github_app;
167 Alcotest.test_case "parse_token extra fields" `Quick
168 test_parse_token_extra_fields;
169 Alcotest.test_case "parse_token invalid json" `Quick
170 test_parse_token_invalid_json;
171 Alcotest.test_case "refresh request body" `Quick test_refresh_request_body;
172 ] )