Provisioning: count for Connection reference in Validation and Tester

This commit is contained in:
Daniele Ferru
2026-01-14 23:35:30 +01:00
parent 91ab753368
commit 39a5836ff0
23 changed files with 2423 additions and 522 deletions
@@ -22,6 +22,7 @@ type Client interface {
// Apps and installations
GetApp(ctx context.Context) (App, error)
GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error)
CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error)
}
// App represents a Github App.
@@ -42,6 +43,14 @@ type AppInstallation struct {
Enabled bool
}
// InstallationToken represents a Github App Installation Access Token.
type InstallationToken struct {
// Token is the access token value.
Token string
// ExpiresAt is the expiration time of the token.
ExpiresAt string
}
type githubClient struct {
gh *github.Client
}
@@ -61,7 +70,6 @@ func (r *githubClient) GetApp(ctx context.Context) (App, error) {
return App{}, err
}
// TODO(ferruvich): do we need any other info?
return App{
ID: app.GetID(),
Slug: app.GetSlug(),
@@ -85,9 +93,34 @@ func (r *githubClient) GetAppInstallation(ctx context.Context, installationID st
return AppInstallation{}, err
}
// TODO(ferruvich): do we need any other info?
return AppInstallation{
ID: installation.GetID(),
Enabled: installation.GetSuspendedAt().IsZero(),
}, nil
}
// CreateInstallationAccessToken creates an installation access token scoped to a specific repository.
func (r *githubClient) CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error) {
id, err := strconv.Atoi(installationID)
if err != nil {
return InstallationToken{}, fmt.Errorf("invalid installation ID: %s", installationID)
}
opts := &github.InstallationTokenOptions{
Repositories: []string{repo},
}
token, _, err := r.gh.Apps.CreateInstallationToken(ctx, int64(id), opts)
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return InstallationToken{}, ErrServiceUnavailable
}
return InstallationToken{}, err
}
return InstallationToken{
Token: token.GetToken(),
ExpiresAt: token.GetExpiresAt().String(),
}, nil
}
@@ -21,6 +21,64 @@ func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
// CreateInstallationAccessToken provides a mock function with given fields: ctx, installationID, repo
func (_m *MockClient) CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error) {
ret := _m.Called(ctx, installationID, repo)
if len(ret) == 0 {
panic("no return value specified for CreateInstallationAccessToken")
}
var r0 InstallationToken
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) (InstallationToken, error)); ok {
return rf(ctx, installationID, repo)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) InstallationToken); ok {
r0 = rf(ctx, installationID, repo)
} else {
r0 = ret.Get(0).(InstallationToken)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, installationID, repo)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_CreateInstallationAccessToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateInstallationAccessToken'
type MockClient_CreateInstallationAccessToken_Call struct {
*mock.Call
}
// CreateInstallationAccessToken is a helper method to define mock.On call
// - ctx context.Context
// - installationID string
// - repo string
func (_e *MockClient_Expecter) CreateInstallationAccessToken(ctx interface{}, installationID interface{}, repo interface{}) *MockClient_CreateInstallationAccessToken_Call {
return &MockClient_CreateInstallationAccessToken_Call{Call: _e.mock.On("CreateInstallationAccessToken", ctx, installationID, repo)}
}
func (_c *MockClient_CreateInstallationAccessToken_Call) Run(run func(ctx context.Context, installationID string, repo string)) *MockClient_CreateInstallationAccessToken_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockClient_CreateInstallationAccessToken_Call) Return(_a0 InstallationToken, _a1 error) *MockClient_CreateInstallationAccessToken_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_CreateInstallationAccessToken_Call) RunAndReturn(run func(context.Context, string, string) (InstallationToken, error)) *MockClient_CreateInstallationAccessToken_Call {
_c.Call.Return(run)
return _c
}
// GetApp provides a mock function with given fields: ctx
func (_m *MockClient) GetApp(ctx context.Context) (App, error) {
ret := _m.Called(ctx)
@@ -295,3 +295,175 @@ func TestGithubClient_GetAppInstallation(t *testing.T) {
})
}
}
func TestGithubClient_CreateInstallationAccessToken(t *testing.T) {
tests := []struct {
name string
mockHandler *http.Client
installationID string
repo string
wantToken conngh.InstallationToken
wantErr bool
errContains string
}{
{
name: "create installation token successfully",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
expiresAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
token := &github.InstallationToken{
Token: github.Ptr("ghs_test_token_123456789"),
ExpiresAt: &github.Timestamp{Time: expiresAt},
}
w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(token))
}),
),
),
installationID: "12345",
repo: "test-repo",
wantToken: conngh.InstallationToken{
Token: "ghs_test_token_123456789",
ExpiresAt: "2024-01-01 00:00:00 +0000 UTC",
},
wantErr: false,
},
{
name: "invalid installation ID",
mockHandler: mockhub.NewMockedHTTPClient(),
installationID: "not-a-number",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
errContains: "invalid installation ID",
},
{
name: "service unavailable",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusServiceUnavailable,
},
Message: "Service unavailable",
}))
}),
),
),
installationID: "12345",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
errContains: conngh.ErrServiceUnavailable.Error(),
},
{
name: "installation not found",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusNotFound,
},
Message: "Not Found",
}))
}),
),
),
installationID: "99999",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
},
{
name: "unauthorized error",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusUnauthorized,
},
Message: "Bad credentials",
}))
}),
),
),
installationID: "12345",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
},
{
name: "forbidden - no permissions for repository",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusForbidden,
},
Message: "Resource not accessible by integration",
}))
}),
),
),
installationID: "12345",
repo: "private-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
},
{
name: "internal server error",
mockHandler: mockhub.NewMockedHTTPClient(
mockhub.WithRequestMatchHandler(
mockhub.PostAppInstallationsAccessTokensByInstallationId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusInternalServerError,
},
Message: "Internal server error",
}))
}),
),
),
installationID: "12345",
repo: "test-repo",
wantToken: conngh.InstallationToken{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ghClient := github.NewClient(tt.mockHandler)
client := conngh.NewClient(ghClient)
token, err := client.CreateInstallationAccessToken(context.Background(), tt.installationID, tt.repo)
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantToken, token)
})
}
}
@@ -10,6 +10,7 @@ import (
"github.com/golang-jwt/jwt/v4"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/validation/field"
@@ -20,18 +21,26 @@ type GithubFactory interface {
New(ctx context.Context, ghToken common.RawSecureValue) Client
}
type ConnectionSecrets struct {
PrivateKey common.RawSecureValue
Token common.RawSecureValue
}
type Connection struct {
obj *provisioning.Connection
ghFactory GithubFactory
secrets ConnectionSecrets
}
func NewConnection(
obj *provisioning.Connection,
factory GithubFactory,
secrets ConnectionSecrets,
) Connection {
return Connection{
obj: obj,
ghFactory: factory,
secrets: secrets,
}
}
@@ -51,14 +60,15 @@ func (c *Connection) Mutate(_ context.Context) error {
c.obj.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, c.obj.Spec.GitHub.InstallationID)
// Generate JWT token if private key is being provided.
// Same as for the spec.Github, if such a field is required, Validation will take care of that.
if !c.obj.Secure.PrivateKey.Create.IsZero() {
token, err := generateToken(c.obj.Spec.GitHub.AppID, c.obj.Secure.PrivateKey.Create)
// Generate token only if one of the following cases are true
// - The object is being created now (generation == 0)
// - The token is not there
// - A new Private key is being submitted
if c.obj.Generation == 0 || c.secrets.Token.IsZero() || !c.obj.Secure.PrivateKey.Create.IsZero() {
token, err := generateToken(c.obj.Spec.GitHub.AppID, c.secrets.PrivateKey)
if err != nil {
return fmt.Errorf("failed to generate JWT token: %w", err)
}
// Store the generated token
c.obj.Secure.Token = common.InlineSecureValue{Create: token}
}
@@ -117,10 +127,10 @@ func (c *Connection) Validate(ctx context.Context) error {
return toError(c.obj.GetName(), list)
}
if c.obj.Secure.PrivateKey.IsZero() {
if c.secrets.PrivateKey.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection"))
}
if c.obj.Secure.Token.IsZero() {
if c.secrets.Token.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "token"), "token must be specified for GitHub connection"))
}
if !c.obj.Secure.ClientSecret.IsZero() {
@@ -150,7 +160,7 @@ func (c *Connection) Validate(ctx context.Context) error {
// validateAppAndInstallation validates the appID and installationID against the given github token.
func (c *Connection) validateAppAndInstallation(ctx context.Context) *field.Error {
ghClient := c.ghFactory.New(ctx, c.obj.Secure.Token.Create)
ghClient := c.ghFactory.New(ctx, c.secrets.Token)
app, err := ghClient.GetApp(ctx)
if err != nil {
@@ -187,6 +197,35 @@ func toError(name string, list field.ErrorList) error {
)
}
// GenerateRepositoryToken generates a repository-scoped access token.
func (c *Connection) GenerateRepositoryToken(ctx context.Context, repo *provisioning.Repository) (common.RawSecureValue, error) {
if repo == nil {
return "", errors.New("a repository is required to generate a token")
}
if c.obj.Spec.GitHub == nil {
return "", errors.New("connection is not a GitHub connection")
}
if repo.Spec.GitHub == nil {
return "", errors.New("repository is not a GitHub repo")
}
_, repoName, err := github.ParseOwnerRepoGithub(repo.Spec.GitHub.URL)
if err != nil {
return "", fmt.Errorf("failed to parse repo URL: %w", err)
}
// Create the GitHub client with the JWT token
ghClient := c.ghFactory.New(ctx, c.secrets.Token)
// Create an installation access token scoped to this repository
installationToken, err := ghClient.CreateInstallationAccessToken(ctx, c.obj.Spec.GitHub.InstallationID, repoName)
if err != nil {
return "", fmt.Errorf("failed to create installation access token: %w", err)
}
return common.RawSecureValue(installationToken.Token), nil
}
var (
_ connection.Connection = (*Connection)(nil)
)
@@ -1,11 +1,13 @@
package github
package github_test
import (
"context"
"encoding/base64"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -43,132 +45,386 @@ B8Uc0WUgheB4+yVKGnYpYaSOgFFI5+1BYUva/wDHLy2pWHz39Usb
-----END RSA PRIVATE KEY-----`
func TestConnection_Mutate(t *testing.T) {
t.Run("should add URL to Github connection", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
tests := []struct {
name string
connection *provisioning.Connection
secrets github.ConnectionSecrets
wantErr bool
validateError func(t *testing.T, err error)
validateResult func(t *testing.T, connection *provisioning.Connection)
}{
{
name: "should add URL to Github connection",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue(privateKeyBase64),
},
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
secrets: github.ConnectionSecrets{
PrivateKey: common.NewSecretValue(privateKeyBase64),
},
wantErr: false,
validateResult: func(t *testing.T, connection *provisioning.Connection) {
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
},
},
{
name: "should generate JWT token when private key is provided",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue(privateKeyBase64),
},
},
},
}
mockFactory := NewMockGithubFactory(t)
conn := NewConnection(c, mockFactory)
require.NoError(t, conn.Mutate(context.Background()))
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
})
t.Run("should generate JWT token when private key is provided", func(t *testing.T) {
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
secrets: github.ConnectionSecrets{
PrivateKey: common.NewSecretValue(privateKeyBase64),
},
wantErr: false,
validateResult: func(t *testing.T, connection *provisioning.Connection) {
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
assert.False(t, connection.Secure.Token.Create.IsZero(), "JWT token should be generated")
},
},
{
name: "should not generate JWT token when token is already there",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection", Generation: 1},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
// The private key is already in the stoere
Name: "somePrivateKey",
},
Token: common.InlineSecureValue{
Create: common.NewSecretValue("someToken"),
},
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue(privateKeyBase64),
secrets: github.ConnectionSecrets{
PrivateKey: common.NewSecretValue(privateKeyBase64),
Token: common.NewSecretValue("someToken"),
},
wantErr: false,
validateResult: func(t *testing.T, connection *provisioning.Connection) {
assert.Equal(t, "https://github.com/settings/installations/456", connection.Spec.URL)
assert.False(t, connection.Secure.Token.Create.IsZero(), "JWT token should be generated")
assert.Equal(t, "someToken", connection.Secure.Token.Create.DangerouslyExposeAndConsumeValue())
},
},
{
name: "should do nothing when GitHub config is nil",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "clientID",
},
},
},
}
mockFactory := NewMockGithubFactory(t)
conn := NewConnection(c, mockFactory)
require.NoError(t, conn.Mutate(context.Background()))
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
assert.False(t, c.Secure.Token.Create.IsZero(), "JWT token should be generated")
})
t.Run("should do nothing when GitHub config is nil", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "clientID",
secrets: github.ConnectionSecrets{},
wantErr: false,
},
{
name: "should fail when private key is not base64",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("invalid-key"),
},
},
},
}
mockFactory := NewMockGithubFactory(t)
conn := NewConnection(c, mockFactory)
require.NoError(t, conn.Mutate(context.Background()))
})
t.Run("should fail when private key is not base64", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
secrets: github.ConnectionSecrets{
PrivateKey: "invalid-key",
},
wantErr: true,
validateError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "failed to generate JWT token")
assert.Contains(t, err.Error(), "failed to decode base64 private key")
},
},
{
name: "should fail when private key is invalid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))),
},
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("invalid-key"),
secrets: github.ConnectionSecrets{},
wantErr: true,
validateError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "failed to generate JWT token")
assert.Contains(t, err.Error(), "failed to parse private key")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockFactory := github.NewMockGithubFactory(t)
conn := github.NewConnection(tt.connection, mockFactory, tt.secrets)
err := conn.Mutate(context.Background())
if tt.wantErr {
require.Error(t, err)
if tt.validateError != nil {
tt.validateError(t, err)
}
} else {
require.NoError(t, err)
if tt.validateResult != nil {
tt.validateResult(t, tt.connection)
}
}
})
}
}
func TestConnection_GenerateRepositoryToken(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
repo *provisioning.Repository
setupMock func(*github.MockGithubFactory)
expectedToken common.RawSecureValue
expectedError string
}{
{
name: "success",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.RawSecureValue("jwt-token"),
},
},
},
}
mockFactory := NewMockGithubFactory(t)
conn := NewConnection(c, mockFactory)
err := conn.Mutate(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to generate JWT token")
assert.Contains(t, err.Error(), "failed to decode base64 private key")
})
t.Run("should fail when private key is invalid", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test-owner/test-repo",
},
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))),
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("jwt-token")).Return(mockClient)
mockClient.EXPECT().CreateInstallationAccessToken(mock.Anything, "456", "test-repo").
Return(github.InstallationToken{Token: "ghs_repository_token_123", ExpiresAt: "2024-01-01T00:00:00Z"}, nil)
},
expectedToken: common.RawSecureValue("ghs_repository_token_123"),
},
{
name: "nil repository returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
},
}
repo: nil,
expectedError: "a repository is required to generate a token",
},
{
name: "connection without GitHub config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "clientID",
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test-owner/test-repo",
},
},
},
expectedError: "connection is not a GitHub connection",
},
{
name: "repository without GitHub config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.RawSecureValue("jwt-token"),
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: nil,
},
},
expectedError: "repository is not a GitHub repo",
},
{
name: "invalid repository URL returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.RawSecureValue("jwt-token"),
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "invalid-url",
},
},
},
expectedError: "failed to parse repo URL",
},
{
name: "GitHub API error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.RawSecureValue("jwt-token"),
},
},
},
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/test-owner/test-repo",
},
},
},
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("jwt-token")).Return(mockClient)
mockClient.EXPECT().CreateInstallationAccessToken(mock.Anything, "456", "test-repo").
Return(github.InstallationToken{}, errors.New("API rate limit exceeded"))
},
expectedError: "failed to create installation access token",
},
}
mockFactory := NewMockGithubFactory(t)
conn := NewConnection(c, mockFactory)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockFactory := github.NewMockGithubFactory(t)
if tt.setupMock != nil {
tt.setupMock(mockFactory)
}
err := conn.Mutate(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to generate JWT token")
assert.Contains(t, err.Error(), "failed to parse private key")
})
conn := github.NewConnection(tt.connection, mockFactory, github.ConnectionSecrets{
Token: tt.connection.Secure.Token.Create,
PrivateKey: tt.connection.Secure.PrivateKey.Create,
})
token, err := conn.GenerateRepositoryToken(context.Background(), tt.repo)
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedToken, token)
}
})
}
}
func TestConnection_Validate(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
setupMock func(*MockGithubFactory)
setupMock func(*github.MockGithubFactory)
wantErr bool
errMsgContains []string
}{
@@ -314,12 +570,12 @@ func TestConnection_Validate(t *testing.T) {
},
},
wantErr: false,
setupMock: func(mockFactory *MockGithubFactory) {
mockClient := NewMockClient(t)
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil)
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{ID: 456}, nil)
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 123, Slug: "test-app"}, nil)
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(github.AppInstallation{ID: 456}, nil)
},
},
{
@@ -344,11 +600,11 @@ func TestConnection_Validate(t *testing.T) {
},
wantErr: true,
errMsgContains: []string{"spec.token", "[REDACTED]"},
setupMock: func(mockFactory *MockGithubFactory) {
mockClient := NewMockClient(t)
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().GetApp(mock.Anything).Return(App{}, assert.AnError)
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{}, assert.AnError)
},
},
{
@@ -373,11 +629,11 @@ func TestConnection_Validate(t *testing.T) {
},
wantErr: true,
errMsgContains: []string{"spec.appID"},
setupMock: func(mockFactory *MockGithubFactory) {
mockClient := NewMockClient(t)
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 444, Slug: "test-app"}, nil)
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 444, Slug: "test-app"}, nil)
},
},
{
@@ -402,24 +658,27 @@ func TestConnection_Validate(t *testing.T) {
},
wantErr: true,
errMsgContains: []string{"spec.installationID", "456"},
setupMock: func(mockFactory *MockGithubFactory) {
mockClient := NewMockClient(t)
setupMock: func(mockFactory *github.MockGithubFactory) {
mockClient := github.NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil)
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{}, assert.AnError)
mockClient.EXPECT().GetApp(mock.Anything).Return(github.App{ID: 123, Slug: "test-app"}, nil)
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(github.AppInstallation{}, assert.AnError)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockFactory := NewMockGithubFactory(t)
mockFactory := github.NewMockGithubFactory(t)
if tt.setupMock != nil {
tt.setupMock(mockFactory)
}
conn := NewConnection(tt.connection, mockFactory)
conn := github.NewConnection(tt.connection, mockFactory, github.ConnectionSecrets{
PrivateKey: tt.connection.Secure.PrivateKey.Create,
Token: tt.connection.Secure.Token.Create,
})
err := conn.Validate(context.Background())
if tt.wantErr {
assert.Error(t, err)
@@ -10,27 +10,51 @@ import (
)
type extra struct {
factory GithubFactory
factory GithubFactory
decrypter connection.Decrypter
}
func (e *extra) Type() provisioning.ConnectionType {
return provisioning.GithubConnectionType
}
func (e *extra) Build(ctx context.Context, connection *provisioning.Connection) (connection.Connection, error) {
func (e *extra) Build(ctx context.Context, conn *provisioning.Connection) (connection.Connection, error) {
logger := logging.FromContext(ctx)
if connection == nil || connection.Spec.GitHub == nil {
if conn == nil || conn.Spec.GitHub == nil {
logger.Error("connection is nil or github info is nil")
return nil, fmt.Errorf("invalid github connection")
}
c := NewConnection(connection, e.factory)
// Decrypt secure values
secure := e.decrypter(conn)
// Decrypt private key
pKey, err := secure.PrivateKey(ctx)
if err != nil {
logger.Error("Failed to decrypt private key", "error", err)
return nil, err
}
// Decrypt token
t, err := secure.Token(ctx)
if err != nil {
logger.Error("Failed to decrypt token", "error", err)
return nil, err
}
c := NewConnection(conn, e.factory, ConnectionSecrets{
PrivateKey: pKey,
Token: t,
})
return &c, nil
}
func Extra(factory GithubFactory) connection.Extra {
func Extra(decrypter connection.Decrypter, factory GithubFactory) connection.Extra {
return &extra{
factory: factory,
decrypter: decrypter,
factory: factory,
}
}
@@ -2,9 +2,11 @@ package github_test
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
"github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
@@ -12,115 +14,218 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type mockSecureValues struct {
privateKey common.RawSecureValue
privateKeyErr error
clientSecret common.RawSecureValue
clientSecErr error
token common.RawSecureValue
tokenErr error
}
func (m *mockSecureValues) PrivateKey(_ context.Context) (common.RawSecureValue, error) {
return m.privateKey, m.privateKeyErr
}
func (m *mockSecureValues) ClientSecret(_ context.Context) (common.RawSecureValue, error) {
return m.clientSecret, m.clientSecErr
}
func (m *mockSecureValues) Token(_ context.Context) (common.RawSecureValue, error) {
return m.token, m.tokenErr
}
func TestExtra_Type(t *testing.T) {
t.Run("should return GithubConnectionType", func(t *testing.T) {
mockFactory := github.NewMockGithubFactory(t)
e := github.Extra(mockFactory)
result := e.Type()
assert.Equal(t, provisioning.GithubConnectionType, result)
})
mockFactory := github.NewMockGithubFactory(t)
decrypter := func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{}
}
e := github.Extra(decrypter, mockFactory)
result := e.Type()
assert.Equal(t, provisioning.GithubConnectionType, result)
}
func TestExtra_Build(t *testing.T) {
t.Run("should successfully build connection", func(t *testing.T) {
ctx := context.Background()
conn := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
tests := []struct {
name string
conn *provisioning.Connection
setupDecrypter func() connection.Decrypter
expectedError string
}{
{
name: "success with valid connection",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123456",
InstallationID: "789012",
},
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Create: common.NewSecretValue("test-private-key"),
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKey: common.RawSecureValue("test-private-key"),
token: common.RawSecureValue("test-token"),
}
}
},
},
{
name: "nil connection",
conn: nil,
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{}
}
},
expectedError: "invalid github connection",
},
{
name: "connection without github config",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: nil,
},
},
}
mockFactory := github.NewMockGithubFactory(t)
e := github.Extra(mockFactory)
result, err := e.Build(ctx, conn)
require.NoError(t, err)
require.NotNil(t, result)
})
t.Run("should handle different connection configurations", func(t *testing.T) {
ctx := context.Background()
conn := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "another-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "789",
InstallationID: "101112",
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{}
}
},
expectedError: "invalid github connection",
},
{
name: "error decrypting private key",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123456",
InstallationID: "789012",
},
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "existing-private-key",
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKeyErr: errors.New("failed to decrypt private key"),
}
}
},
expectedError: "failed to decrypt private key",
},
{
name: "error decrypting token",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Token: common.InlineSecureValue{
Name: "existing-token",
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123456",
InstallationID: "789012",
},
},
},
}
mockFactory := github.NewMockGithubFactory(t)
e := github.Extra(mockFactory)
result, err := e.Build(ctx, conn)
require.NoError(t, err)
require.NotNil(t, result)
})
t.Run("should build connection with background context", func(t *testing.T) {
ctx := context.Background()
conn := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKey: common.RawSecureValue("test-private-key"),
tokenErr: errors.New("failed to decrypt token"),
}
}
},
expectedError: "failed to decrypt token",
},
{
name: "success with empty secure values",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-connection",
Namespace: "default",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123456",
InstallationID: "789012",
},
},
},
}
mockFactory := github.NewMockGithubFactory(t)
e := github.Extra(mockFactory)
result, err := e.Build(ctx, conn)
require.NoError(t, err)
require.NotNil(t, result)
})
t.Run("should always pass empty token to factory.New", func(t *testing.T) {
ctx := context.Background()
conn := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKey: common.RawSecureValue(""),
token: common.RawSecureValue(""),
}
}
},
},
{
name: "success with different app and installation IDs",
conn: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "another-connection",
Namespace: "prod",
},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "999888",
InstallationID: "777666",
},
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.NewSecretValue("some-token"),
},
setupDecrypter: func() connection.Decrypter {
return func(c *provisioning.Connection) connection.SecureValues {
return &mockSecureValues{
privateKey: common.RawSecureValue("another-private-key"),
token: common.RawSecureValue("another-token"),
}
}
},
}
},
}
mockFactory := github.NewMockGithubFactory(t)
e := github.Extra(mockFactory)
result, err := e.Build(ctx, conn)
require.NoError(t, err)
require.NotNil(t, result)
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
mockFactory := github.NewMockGithubFactory(t)
decrypter := tt.setupDecrypter()
e := github.Extra(decrypter, mockFactory)
result, err := e.Build(ctx, tt.conn)
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, result)
} else {
require.NoError(t, err)
assert.NotNil(t, result)
}
})
}
}