1 package ghinstallation
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io/ioutil"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/google/go-cmp/cmp"
17 "github.com/google/go-github/v45/github"
18 )
19
20 const (
21 installationID = 1
22 appID = 2
23 token = "abc123"
24 )
25
26 var key = []byte(`-----BEGIN RSA PRIVATE KEY-----
27 MIIEpQIBAAKCAQEA0BUezcR7uycgZsfVLlAf4jXP7uFpVh4geSTY39RvYrAll0yh
28 q7uiQypP2hjQJ1eQXZvkAZx0v9lBYJmX7e0HiJckBr8+/O2kARL+GTCJDJZECpjy
29 97yylbzGBNl3s76fZ4CJ+4f11fCh7GJ3BJkMf9NFhe8g1TYS0BtSd/sauUQEuG/A
30 3fOJxKTNmICZr76xavOQ8agA4yW9V5hKcrbHzkfecg/sQsPMmrXixPNxMsqyOMmg
31 jdJ1aKr7ckEhd48ft4bPMO4DtVL/XFdK2wJZZ0gXJxWiT1Ny41LVql97Odm+OQyx
32 tcayMkGtMb1nwTcVVl+RG2U5E1lzOYpcQpyYFQIDAQABAoIBAAfUY55WgFlgdYWo
33 i0r81NZMNBDHBpGo/IvSaR6y/aX2/tMcnRC7NLXWR77rJBn234XGMeQloPb/E8iw
34 vtjDDH+FQGPImnQl9P/dWRZVjzKcDN9hNfNAdG/R9JmGHUz0JUddvNNsIEH2lgEx
35 C01u/Ntqdbk+cDvVlwuhm47MMgs6hJmZtS1KDPgYJu4IaB9oaZFN+pUyy8a1w0j9
36 RAhHpZrsulT5ThgCra4kKGDNnk2yfI91N9lkP5cnhgUmdZESDgrAJURLS8PgInM4
37 YPV9L68tJCO4g6k+hFiui4h/4cNXYkXnaZSBUoz28ICA6e7I3eJ6Y1ko4ou+Xf0V
38 csM8VFkCgYEA7y21JfECCfEsTHwwDg0fq2nld4o6FkIWAVQoIh6I6o6tYREmuZ/1
39 s81FPz/lvQpAvQUXGZlOPB9eW6bZZFytcuKYVNE/EVkuGQtpRXRT630CQiqvUYDZ
40 4FpqdBQUISt8KWpIofndrPSx6JzI80NSygShQsScWFw2wBIQAnV3TpsCgYEA3reL
41 L7AwlxCacsPvkazyYwyFfponblBX/OvrYUPPaEwGvSZmE5A/E4bdYTAixDdn4XvE
42 ChwpmRAWT/9C6jVJ/o1IK25dwnwg68gFDHlaOE+B5/9yNuDvVmg34PWngmpucFb/
43 6R/kIrF38lEfY0pRb05koW93uj1fj7Uiv+GWRw8CgYEAn1d3IIDQl+kJVydBKItL
44 tvoEur/m9N8wI9B6MEjhdEp7bXhssSvFF/VAFeQu3OMQwBy9B/vfaCSJy0t79uXb
45 U/dr/s2sU5VzJZI5nuDh67fLomMni4fpHxN9ajnaM0LyI/E/1FFPgqM+Rzb0lUQb
46 yqSM/ptXgXJls04VRl4VjtMCgYEAprO/bLx2QjxdPpXGFcXbz6OpsC92YC2nDlsP
47 3cfB0RFG4gGB2hbX/6eswHglLbVC/hWDkQWvZTATY2FvFps4fV4GrOt5Jn9+rL0U
48 elfC3e81Dw+2z7jhrE1ptepprUY4z8Fu33HNcuJfI3LxCYKxHZ0R2Xvzo+UYSBqO
49 ng0eTKUCgYEAxW9G4FjXQH0bjajntjoVQGLRVGWnteoOaQr/cy6oVii954yNMKSP
50 rezRkSNbJ8cqt9XQS+NNJ6Xwzl3EbuAt6r8f8VO1TIdRgFOgiUXRVNZ3ZyW8Hegd
51 kGTL0A6/0yAu9qQZlFbaD5bWhQo7eyx63u4hZGppBhkTSPikOYUPCH8=
52 -----END RSA PRIVATE KEY-----`)
53
54 func TestNew(t *testing.T) {
55 var authed bool
56 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57 if r.Header.Get("Accept") != acceptHeader {
58 t.Fatalf("Request URI %q accept header got %q want: %q", r.RequestURI, r.Header.Get("Accept"), acceptHeader)
59 }
60 switch r.RequestURI {
61 case fmt.Sprintf("/app/installations/%d/access_tokens", installationID):
62
63 js, _ := json.Marshal(accessToken{
64 Token: token,
65 ExpiresAt: time.Now().Add(5 * time.Minute),
66 })
67 fmt.Fprintln(w, string(js))
68 authed = true
69 case "/auth/with/installation/token/endpoint":
70 if want := "token " + token; r.Header.Get("Authorization") != want {
71 t.Fatalf("Installation token got: %q want: %q", r.Header.Get("Authorization"), want)
72 }
73 default:
74 t.Fatalf("unexpected URI: %q", r.RequestURI)
75 }
76 }))
77 defer ts.Close()
78
79 tr, err := New(&http.Transport{}, appID, installationID, key)
80 if err != nil {
81 t.Fatal("unexpected error:", err)
82 }
83 tr.BaseURL = ts.URL
84
85 client := http.Client{Transport: tr}
86 _, err = client.Get(ts.URL + "/auth/with/installation/token/endpoint")
87 if err != nil {
88 t.Fatal("unexpected error from client:", err)
89 }
90
91 if !authed {
92 t.Fatal("Expected fetch of access_token but none occurred")
93 }
94
95
96 tr.token.ExpiresAt = time.Now().Add(time.Hour)
97 authed = false
98
99 _, err = client.Get(ts.URL + "/auth/with/installation/token/endpoint")
100 if err != nil {
101 t.Fatal("unexpected error from client:", err)
102 }
103
104 if authed {
105 t.Fatal("Unexpected fetch of access_token")
106 }
107
108
109 tr.token.ExpiresAt = time.Unix(0, 0)
110
111 _, err = client.Get(ts.URL + "/auth/with/installation/token/endpoint")
112 if err != nil {
113 t.Fatal("unexpected error from client:", err)
114 }
115
116 if !authed {
117 t.Fatal("Expected fetch of access_token but none occurred")
118 }
119 }
120
121 func TestNewKeyFromFile(t *testing.T) {
122 tmpfile, err := ioutil.TempFile("", "example")
123 if err != nil {
124 t.Fatal(err)
125 }
126 defer os.Remove(tmpfile.Name())
127
128 if _, err := tmpfile.Write(key); err != nil {
129 t.Fatal(err)
130 }
131 if err := tmpfile.Close(); err != nil {
132 t.Fatal(err)
133 }
134
135 _, err = NewKeyFromFile(&http.Transport{}, appID, installationID, tmpfile.Name())
136 if err != nil {
137 t.Fatal("unexpected error:", err)
138 }
139 }
140
141 func TestNew_appendHeader(t *testing.T) {
142 var headers http.Header
143 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144 headers = r.Header
145 fmt.Fprintln(w, `{}`)
146 }))
147 defer ts.Close()
148
149
150 myheader := "my-header"
151 req, err := http.NewRequest("GET", ts.URL+"/auth/with/installation/token/endpoint", nil)
152 if err != nil {
153 t.Fatal("unexpected error from http.NewRequest:", err)
154 }
155 req.Header.Add("Accept", myheader)
156
157 tr, err := New(&http.Transport{}, appID, installationID, key)
158 if err != nil {
159 t.Fatal("unexpected error:", err)
160 }
161 tr.BaseURL = ts.URL
162
163 client := http.Client{Transport: tr}
164 _, err = client.Do(req)
165 if err != nil {
166 t.Fatal("unexpected error from client:", err)
167 }
168
169 found := false
170 for _, v := range headers["Accept"] {
171 if v == myheader {
172 found = true
173 break
174 }
175 }
176
177 if !found {
178 t.Errorf("could not find %v in request's accept headers: %v", myheader, headers["Accept"])
179 }
180 }
181
182 func TestRefreshTokenWithParameters(t *testing.T) {
183 installationTokenOptions := &github.InstallationTokenOptions{
184 RepositoryIDs: []int64{1234},
185 Permissions: &github.InstallationPermissions{
186 Contents: github.String("write"),
187 Issues: github.String("read"),
188 },
189 }
190
191
192 body, err := GetReadWriter(installationTokenOptions)
193 if err != nil {
194 t.Fatalf("error calling GetReadWriter: %v", err)
195 }
196
197
198 wantBody, _ := GetReadWriter(installationTokenOptions)
199 wantBodyBytes := new(bytes.Buffer)
200 wantBodyBytes.ReadFrom(wantBody)
201 wantBodyString := wantBodyBytes.String()
202
203 roundTripper := RoundTrip{
204 rt: func(req *http.Request) (*http.Response, error) {
205
206 var gotBodyBytes []byte
207 gotBodyBytes, _ = ioutil.ReadAll(req.Body)
208 req.Body = ioutil.NopCloser(bytes.NewBuffer(gotBodyBytes))
209 gotBodyString := string(gotBodyBytes)
210
211
212 if diff := cmp.Diff(wantBodyString, gotBodyString); diff != "" {
213 t.Errorf("HTTP body want->got: %s", diff)
214 }
215
216
217 accessToken := accessToken{
218 Token: "token_string",
219 ExpiresAt: time.Now(),
220 Repositories: []github.Repository{{
221 ID: github.Int64(1234),
222 }},
223 Permissions: github.InstallationPermissions{
224 Contents: github.String("write"),
225 Issues: github.String("read"),
226 },
227 }
228 tokenReadWriter, err := GetReadWriter(accessToken)
229 if err != nil {
230 return nil, fmt.Errorf("error converting token into io.ReadWriter: %+v", err)
231 }
232 tokenBody := ioutil.NopCloser(tokenReadWriter)
233 return &http.Response{
234 Body: tokenBody,
235 StatusCode: 200,
236 }, nil
237 },
238 }
239
240 tr, err := New(roundTripper, appID, installationID, key)
241 if err != nil {
242 t.Fatal("unexpected error:", err)
243 }
244 tr.InstallationTokenOptions = installationTokenOptions
245
246 req, err := http.NewRequest("POST", fmt.Sprintf("%s/app/installations/%v/access_tokens", tr.BaseURL, tr.installationID), body)
247 if err != nil {
248 t.Fatal("unexpected error:", err)
249 }
250 if _, err := tr.RoundTrip(req); err != nil {
251 t.Fatalf("error calling RoundTrip: %v", err)
252 }
253 }
254
255 func TestRefreshTokenWithTrailingSlashBaseURL(t *testing.T) {
256 installationTokenOptions := &github.InstallationTokenOptions{
257 RepositoryIDs: []int64{1234},
258 Permissions: &github.InstallationPermissions{
259 Contents: github.String("write"),
260 Issues: github.String("read"),
261 },
262 }
263
264 tokenToBe := "token_string"
265
266
267 wantBody, _ := GetReadWriter(installationTokenOptions)
268 wantBodyBytes := new(bytes.Buffer)
269 wantBodyBytes.ReadFrom(wantBody)
270 wantBodyString := wantBodyBytes.String()
271
272 roundTripper := RoundTrip{
273 rt: func(req *http.Request) (*http.Response, error) {
274 if strings.Contains(req.URL.Path, "//") {
275 return &http.Response{
276 Body: ioutil.NopCloser(strings.NewReader("Forbidden\n")),
277 StatusCode: 401,
278 }, fmt.Errorf("Got simulated 401 Github Forbidden response")
279 }
280
281 if req.URL.Path == "test_endpoint/" && req.Header.Get("Authorization") == fmt.Sprintf("token %s", tokenToBe) {
282 return &http.Response{
283 Body: ioutil.NopCloser(strings.NewReader("Beautiful\n")),
284 StatusCode: 200,
285 }, nil
286 }
287
288
289 var gotBodyBytes []byte
290 gotBodyBytes, _ = ioutil.ReadAll(req.Body)
291 req.Body = ioutil.NopCloser(bytes.NewBuffer(gotBodyBytes))
292 gotBodyString := string(gotBodyBytes)
293
294
295 if diff := cmp.Diff(wantBodyString, gotBodyString); diff != "" {
296 t.Errorf("HTTP body want->got: %s", diff)
297 }
298
299
300 accessToken := accessToken{
301 Token: tokenToBe,
302 ExpiresAt: time.Now(),
303 Repositories: []github.Repository{{
304 ID: github.Int64(1234),
305 }},
306 Permissions: github.InstallationPermissions{
307 Contents: github.String("write"),
308 Issues: github.String("read"),
309 },
310 }
311 tokenReadWriter, err := GetReadWriter(accessToken)
312 if err != nil {
313 return nil, fmt.Errorf("error converting token into io.ReadWriter: %+v", err)
314 }
315 tokenBody := ioutil.NopCloser(tokenReadWriter)
316 return &http.Response{
317 Body: tokenBody,
318 StatusCode: 200,
319 }, nil
320 },
321 }
322
323 tr, err := New(roundTripper, appID, installationID, key)
324 if err != nil {
325 t.Fatal("unexpected error:", err)
326 }
327 tr.InstallationTokenOptions = installationTokenOptions
328 tr.BaseURL = "http://localhost/github/api/v3/"
329
330
331 body, err := GetReadWriter(installationTokenOptions)
332 if err != nil {
333 t.Fatalf("error calling GetReadWriter: %v", err)
334 }
335
336 req, err := http.NewRequest("POST", "http://localhost/test_endpoint/", body)
337 if err != nil {
338 t.Fatal("unexpected error:", err)
339 }
340 res, err := tr.RoundTrip(req)
341 if err != nil {
342 t.Fatalf("error calling RoundTrip: %v", err)
343 }
344 if res.StatusCode != 200 {
345 t.Fatalf("Unexpected RoundTrip response code: %d", res.StatusCode)
346 }
347 }
348
349 func TestRoundTripperContract(t *testing.T) {
350 tr := &Transport{
351 token: &accessToken{
352 ExpiresAt: time.Now().Add(1 * time.Hour),
353 Token: "42",
354 },
355 mu: &sync.Mutex{},
356 tr: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
357 if auth := req.Header.Get("Authorization"); auth != "token 42" {
358 t.Errorf("got unexpected Authorization request header in parent RoundTripper: %q", auth)
359 }
360 return nil, nil
361 }),
362 }
363 req, err := http.NewRequest("GET", "http://localhost", nil)
364 if err != nil {
365 t.Fatal(err)
366 }
367 req.Header.Set("Authorization", "xxx")
368 _, err = tr.RoundTrip(req)
369 if err != nil {
370 t.Fatal(err)
371 }
372 if accept := req.Header.Get("Authorization"); accept != "xxx" {
373 t.Errorf("got unexpected Authorization request header in caller: %q", accept)
374 }
375 }
376
377 type roundTripperFunc func(*http.Request) (*http.Response, error)
378
379 func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
380 return fn(req)
381 }
382
View as plain text