286 lines
9.2 KiB
Go
286 lines
9.2 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type appRoundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f appRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func testAppPrivateKey(t *testing.T) (*rsa.PrivateKey, []byte) {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
|
return key, pemBytes
|
|
}
|
|
|
|
func decodeJWTPart(t *testing.T, part string, out any) {
|
|
t.Helper()
|
|
b, err := base64.RawURLEncoding.DecodeString(part)
|
|
if err != nil {
|
|
t.Fatalf("decode JWT part: %v", err)
|
|
}
|
|
if err := json.Unmarshal(b, out); err != nil {
|
|
t.Fatalf("unmarshal JWT part: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppClient_JWTClaimsAndSignature(t *testing.T) {
|
|
key, pemBytes := testAppPrivateKey(t)
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
client, err := NewAppClient(12345, pemBytes)
|
|
if err != nil {
|
|
t.Fatalf("NewAppClient: %v", err)
|
|
}
|
|
client.now = func() time.Time { return now }
|
|
|
|
token, err := client.AppJWT()
|
|
if err != nil {
|
|
t.Fatalf("AppJWT: %v", err)
|
|
}
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("JWT parts = %d, want 3", len(parts))
|
|
}
|
|
var header map[string]any
|
|
decodeJWTPart(t, parts[0], &header)
|
|
if header["alg"] != "RS256" || header["typ"] != "JWT" {
|
|
t.Fatalf("JWT header = %+v", header)
|
|
}
|
|
var claims map[string]any
|
|
decodeJWTPart(t, parts[1], &claims)
|
|
if claims["iss"] != "12345" {
|
|
t.Fatalf("iss = %#v, want string app id", claims["iss"])
|
|
}
|
|
if got := int64(claims["iat"].(float64)); got != now.Add(-time.Minute).Unix() {
|
|
t.Fatalf("iat = %d", got)
|
|
}
|
|
if got := int64(claims["exp"].(float64)); got != now.Add(9*time.Minute).Unix() {
|
|
t.Fatalf("exp = %d", got)
|
|
}
|
|
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
|
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
t.Fatalf("decode signature: %v", err)
|
|
}
|
|
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signature); err != nil {
|
|
t.Fatalf("verify JWT: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppClient_MintInstallationTokenScopesPermissions(t *testing.T) {
|
|
_, pemBytes := testAppPrivateKey(t)
|
|
expires := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
|
var requestBody map[string]any
|
|
transport := appRoundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path != "/app/installations/77/access_tokens" || r.Method != http.MethodPost {
|
|
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
if err := json.Unmarshal(body, &requestBody); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
responseBody := `{"token":"ghs_install","expires_at":"` + expires.Format(time.RFC3339) + `","permissions":{"contents":"write","pull_requests":"write"}}`
|
|
return &http.Response{
|
|
StatusCode: http.StatusCreated,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(responseBody)),
|
|
}, nil
|
|
})
|
|
|
|
client, err := NewAppClient(123, pemBytes)
|
|
if err != nil {
|
|
t.Fatalf("NewAppClient: %v", err)
|
|
}
|
|
client.baseURL = "https://api.test"
|
|
client.httpClient.Transport = transport
|
|
permissions := InstallationPermissions{"contents": PermissionWrite, "pull_requests": PermissionWrite}
|
|
token, err := client.MintInstallationToken(context.Background(), 77, permissions, []string{"widgets"})
|
|
if err != nil {
|
|
t.Fatalf("MintInstallationToken: %v", err)
|
|
}
|
|
if token.Token != "ghs_install" || !token.ExpiresAt.Equal(expires) {
|
|
t.Fatalf("token = %+v", token)
|
|
}
|
|
gotPermissions, ok := requestBody["permissions"].(map[string]any)
|
|
if !ok || gotPermissions["contents"] != "write" || gotPermissions["pull_requests"] != "write" {
|
|
t.Fatalf("request permissions = %#v", requestBody["permissions"])
|
|
}
|
|
if token.Principal.Kind != TokenCredentialInstallation || token.Principal.InstallationID != 77 {
|
|
t.Fatalf("principal = %+v", token.Principal)
|
|
}
|
|
gotRepositories, ok := requestBody["repositories"].([]any)
|
|
if !ok || len(gotRepositories) != 1 || gotRepositories[0] != "widgets" {
|
|
t.Fatalf("request repositories = %#v", requestBody["repositories"])
|
|
}
|
|
}
|
|
|
|
func TestAppClient_GetInstallation(t *testing.T) {
|
|
_, pemBytes := testAppPrivateKey(t)
|
|
transport := appRoundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path != "/app/installations/77" || r.Method != http.MethodGet {
|
|
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":77,"account":{"id":9,"login":"acme","type":"Organization"},` +
|
|
`"permissions":{"contents":"write"},"suspended_at":null}`,
|
|
)),
|
|
}, nil
|
|
})
|
|
client, err := NewAppClient(123, pemBytes)
|
|
if err != nil {
|
|
t.Fatalf("NewAppClient: %v", err)
|
|
}
|
|
client.baseURL = "https://api.test"
|
|
client.httpClient.Transport = transport
|
|
installation, err := client.GetInstallation(context.Background(), 77)
|
|
if err != nil {
|
|
t.Fatalf("GetInstallation: %v", err)
|
|
}
|
|
if installation.ID != 77 || installation.AccountID != 9 ||
|
|
installation.AccountLogin != "acme" || installation.AccountType != "Organization" ||
|
|
installation.Permissions["contents"] != PermissionWrite {
|
|
t.Fatalf("installation = %+v", installation)
|
|
}
|
|
}
|
|
|
|
func TestAppClient_GetAuthenticatedApp(t *testing.T) {
|
|
_, pemBytes := testAppPrivateKey(t)
|
|
transport := appRoundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path != "/app" || r.Method != http.MethodGet {
|
|
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{
|
|
"id":123,"client_id":"Iv1.client","slug":"kandev-acme","name":"Kandev Acme",
|
|
"owner":{"login":"acme","type":"Organization"},
|
|
"external_url":"https://kandev.example",
|
|
"permissions":{"contents":"write"},"events":["installation"]
|
|
}`)),
|
|
}, nil
|
|
})
|
|
client, err := NewAppClient(123, pemBytes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client.baseURL = "https://api.test"
|
|
client.httpClient.Transport = transport
|
|
app, err := client.GetAuthenticatedApp(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if app.ID != 123 || app.ClientID != "Iv1.client" || app.Slug != "kandev-acme" ||
|
|
app.OwnerLogin != "acme" || app.OwnerType != "Organization" ||
|
|
app.Permissions["contents"] != "write" || len(app.Events) != 1 {
|
|
t.Fatalf("authenticated App = %+v", app)
|
|
}
|
|
}
|
|
|
|
func TestAppClient_GetAuthenticatedAppBoundsProviderResponse(t *testing.T) {
|
|
_, pemBytes := testAppPrivateKey(t)
|
|
client, err := NewAppClient(123, pemBytes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client.baseURL = "https://api.test"
|
|
client.httpClient.Transport = appRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(
|
|
strings.Repeat("x", maxGitHubAppResponseSize+1),
|
|
)),
|
|
}, nil
|
|
})
|
|
_, err = client.GetAuthenticatedApp(context.Background())
|
|
if !errors.Is(err, ErrGitHubAppResponseTooLarge) {
|
|
t.Fatalf("oversized authenticated App error = %v, want %v", err, ErrGitHubAppResponseTooLarge)
|
|
}
|
|
}
|
|
|
|
func TestAppClient_GetWebhookConfig(t *testing.T) {
|
|
_, pemBytes := testAppPrivateKey(t)
|
|
client, err := NewAppClient(123, pemBytes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client.baseURL = "https://api.test"
|
|
client.httpClient.Transport = appRoundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method != http.MethodGet || r.URL.Path != "/app/hook/config" {
|
|
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK, Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"url":"https://kandev.example/webhook","content_type":"json","insecure_ssl":0}`,
|
|
)),
|
|
}, nil
|
|
})
|
|
config, err := client.GetWebhookConfig(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if config.URL != "https://kandev.example/webhook" ||
|
|
config.ContentType != "json" || config.InsecureSSL != "0" {
|
|
t.Fatalf("webhook config = %+v", config)
|
|
}
|
|
}
|
|
|
|
func TestAppClient_MapsInstallationPermissionsToCapabilities(t *testing.T) {
|
|
permissions := InstallationPermissions{
|
|
"metadata": PermissionRead,
|
|
"contents": PermissionWrite,
|
|
"pull_requests": PermissionRead,
|
|
"issues": PermissionWrite,
|
|
"checks": PermissionRead,
|
|
"administration": PermissionRead,
|
|
}
|
|
capabilities := CapabilitiesForPermissions(permissions)
|
|
for _, capability := range []GitHubAppCapability{
|
|
CapabilityRepositoryRead,
|
|
CapabilityGitWrite,
|
|
CapabilityPullRequestRead,
|
|
CapabilityIssueWrite,
|
|
CapabilityChecksRead,
|
|
CapabilityBranchProtectionRead,
|
|
} {
|
|
if !capabilities[capability] {
|
|
t.Errorf("capability %q = false", capability)
|
|
}
|
|
}
|
|
if capabilities[CapabilityPullRequestWrite] {
|
|
t.Error("read-only pull_requests unexpectedly grants write")
|
|
}
|
|
}
|