f82d841955
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
243 lines
9.3 KiB
Go
243 lines
9.3 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type appRegistrationVerifierFunc func(context.Context) (AuthenticatedApp, error)
|
|
|
|
func (f appRegistrationVerifierFunc) GetAuthenticatedApp(ctx context.Context) (AuthenticatedApp, error) {
|
|
return f(ctx)
|
|
}
|
|
|
|
type appRegistrationVerifierWithWebhook struct {
|
|
app AuthenticatedApp
|
|
config AppWebhookConfig
|
|
}
|
|
|
|
func (v appRegistrationVerifierWithWebhook) GetAuthenticatedApp(context.Context) (AuthenticatedApp, error) {
|
|
return v.app, nil
|
|
}
|
|
|
|
func (v appRegistrationVerifierWithWebhook) GetWebhookConfig(context.Context) (AppWebhookConfig, error) {
|
|
return v.config, nil
|
|
}
|
|
|
|
func TestAppRegistrationImportReturnsKnownDuplicateWithoutVerification(t *testing.T) {
|
|
store := newTestStore(t)
|
|
secrets := newFakeConnectionSecrets()
|
|
repository := NewAppRegistrationRepository(store, secrets)
|
|
request := validAppRegistrationImportRequest(t)
|
|
existing := newAppRegistration("existing-registration", request.AppID, "Existing", time.Now().UTC())
|
|
if err := repository.SaveRegistration(context.Background(), existing, DeploymentAppCredentials{
|
|
PrivateKey: "existing-key", ClientSecret: "existing-client", WebhookSecret: "existing-webhook",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
importer := NewAppRegistrationImporter(repository, importPublicResolver(),
|
|
func(int64, []byte) (AppRegistrationVerifier, error) {
|
|
t.Fatal("duplicate import attempted provider verification")
|
|
return nil, nil
|
|
})
|
|
_, err := importer.Import(context.Background(), request)
|
|
var importErr *AppRegistrationImportError
|
|
if !errors.As(err, &importErr) || importErr.Code != AppRegistrationImportAlreadyRegistered ||
|
|
importErr.ExistingRegistrationID != existing.ID {
|
|
t.Fatalf("duplicate import error = %#v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationImportRejectsIdentityAndPolicyMismatchWithoutSecrets(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*AuthenticatedApp)
|
|
wantCode AppRegistrationImportErrorCode
|
|
wantProblem string
|
|
}{
|
|
{
|
|
name: "identity",
|
|
mutate: func(app *AuthenticatedApp) { app.OwnerLogin = "different-owner" },
|
|
wantCode: AppRegistrationImportIdentityMismatch, wantProblem: "owner_login",
|
|
},
|
|
{
|
|
name: "policy",
|
|
mutate: func(app *AuthenticatedApp) { delete(app.Permissions, "contents") },
|
|
wantCode: AppRegistrationImportPolicyMismatch, wantProblem: "permission:contents",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
store := newTestStore(t)
|
|
repository := NewAppRegistrationRepository(store, newFakeConnectionSecrets())
|
|
request := validAppRegistrationImportRequest(t)
|
|
importer := NewAppRegistrationImporter(repository, importPublicResolver(),
|
|
func(int64, []byte) (AppRegistrationVerifier, error) {
|
|
return appRegistrationVerifierFunc(func(context.Context) (AuthenticatedApp, error) {
|
|
app := authenticatedAppForImport(t, request)
|
|
tt.mutate(&app)
|
|
return app, nil
|
|
}), nil
|
|
})
|
|
_, err := importer.Import(context.Background(), request)
|
|
var importErr *AppRegistrationImportError
|
|
if !errors.As(err, &importErr) || importErr.Code != tt.wantCode ||
|
|
!containsString(importErr.Problems, tt.wantProblem) {
|
|
t.Fatalf("import mismatch error = %#v", err)
|
|
}
|
|
for _, secret := range []string{request.ClientSecret, request.PrivateKey, request.WebhookSecret} {
|
|
if strings.Contains(err.Error(), secret) {
|
|
t.Fatalf("import error leaked credential material")
|
|
}
|
|
}
|
|
stored, getErr := store.GetAppRegistration(context.Background(), request.RegistrationID)
|
|
if getErr != nil || stored != nil {
|
|
t.Fatalf("mismatched registration persisted = %+v, err %v", stored, getErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationImportBoundsSecretInputsBeforeVerification(t *testing.T) {
|
|
request := validAppRegistrationImportRequest(t)
|
|
request.PrivateKey = strings.Repeat("k", maxAppRegistrationPrivateKey+1)
|
|
importer := NewAppRegistrationImporter(
|
|
NewAppRegistrationRepository(newTestStore(t), newFakeConnectionSecrets()),
|
|
importPublicResolver(),
|
|
func(int64, []byte) (AppRegistrationVerifier, error) {
|
|
t.Fatal("oversized import reached verifier")
|
|
return nil, nil
|
|
},
|
|
)
|
|
_, err := importer.Import(context.Background(), request)
|
|
if !IsAppRegistrationImportError(err, AppRegistrationImportInvalidRequest) {
|
|
t.Fatalf("oversized import error = %#v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationImportSanitizesVerificationFailure(t *testing.T) {
|
|
request := validAppRegistrationImportRequest(t)
|
|
importer := NewAppRegistrationImporter(
|
|
NewAppRegistrationRepository(newTestStore(t), newFakeConnectionSecrets()),
|
|
importPublicResolver(),
|
|
func(int64, []byte) (AppRegistrationVerifier, error) {
|
|
return nil, errors.New(request.ClientSecret + request.WebhookSecret + request.PrivateKey)
|
|
},
|
|
)
|
|
_, err := importer.Import(context.Background(), request)
|
|
if !IsAppRegistrationImportError(err, AppRegistrationImportVerificationFailed) {
|
|
t.Fatalf("verification failure = %#v", err)
|
|
}
|
|
for _, secret := range []string{request.ClientSecret, request.WebhookSecret, request.PrivateKey} {
|
|
if strings.Contains(err.Error(), secret) {
|
|
t.Fatal("verification error leaked imported credentials")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationImportRejectsWrongExposedWebhookConfig(t *testing.T) {
|
|
store := newTestStore(t)
|
|
repository := NewAppRegistrationRepository(store, newFakeConnectionSecrets())
|
|
request := validAppRegistrationImportRequest(t)
|
|
importer := NewAppRegistrationImporter(repository, importPublicResolver(),
|
|
func(int64, []byte) (AppRegistrationVerifier, error) {
|
|
return appRegistrationVerifierWithWebhook{
|
|
app: authenticatedAppForImport(t, request),
|
|
config: AppWebhookConfig{
|
|
URL: "https://wrong.example/webhook", ContentType: "json", InsecureSSL: "0",
|
|
},
|
|
}, nil
|
|
})
|
|
_, err := importer.Import(context.Background(), request)
|
|
var importErr *AppRegistrationImportError
|
|
if !errors.As(err, &importErr) || importErr.Code != AppRegistrationImportPolicyMismatch ||
|
|
!containsString(importErr.Problems, "webhook") {
|
|
t.Fatalf("webhook policy error = %#v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationImportAcceptsDocumentedAuthenticatedAppShapeAndPersists(t *testing.T) {
|
|
store := newTestStore(t)
|
|
secrets := newFakeConnectionSecrets()
|
|
repository := NewAppRegistrationRepository(store, secrets)
|
|
request := validAppRegistrationImportRequest(t)
|
|
verified := false
|
|
importer := NewAppRegistrationImporter(repository, importPublicResolver(),
|
|
func(appID int64, privateKey []byte) (AppRegistrationVerifier, error) {
|
|
if appID != request.AppID || string(privateKey) != request.PrivateKey {
|
|
t.Fatalf("verifier credentials = app %d key %q", appID, privateKey)
|
|
}
|
|
return appRegistrationVerifierFunc(func(context.Context) (AuthenticatedApp, error) {
|
|
verified = true
|
|
app := authenticatedAppForImport(t, request)
|
|
app.Events = []string{"push", "check_run"}
|
|
return app, nil
|
|
}), nil
|
|
})
|
|
|
|
registration, err := importer.Import(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Import() error = %v", err)
|
|
}
|
|
if !verified {
|
|
t.Fatal("registration persisted without verifying GitHub App identity")
|
|
}
|
|
if registration == nil || registration.ID != request.RegistrationID ||
|
|
registration.Source != AppRegistrationSourceImported || registration.AppID != request.AppID {
|
|
t.Fatalf("imported registration = %+v", registration)
|
|
}
|
|
stored, credentials, err := repository.LoadRegistration(context.Background(), request.RegistrationID)
|
|
if err != nil || stored == nil || stored.ID != request.RegistrationID {
|
|
t.Fatalf("stored registration = %+v, err %v", stored, err)
|
|
}
|
|
if credentials.PrivateKey != request.PrivateKey || credentials.ClientSecret != request.ClientSecret ||
|
|
credentials.WebhookSecret != request.WebhookSecret {
|
|
t.Fatalf("stored credentials do not match import request")
|
|
}
|
|
}
|
|
|
|
func validAppRegistrationImportRequest(t *testing.T) AppRegistrationImportRequest {
|
|
t.Helper()
|
|
_, privateKey := testAppPrivateKey(t)
|
|
return AppRegistrationImportRequest{
|
|
RegistrationID: "22222222-2222-4222-8222-222222222222",
|
|
WorkspaceID: "workspace-1", DisplayName: "Acme automation", GitHubHost: "github.com",
|
|
AppID: 123, ClientID: "Iv1.client", ClientSecret: "client-secret",
|
|
PrivateKey: string(privateKey), WebhookSecret: "webhook-secret", Slug: "kandev-acme",
|
|
OwnerLogin: "acme", OwnerType: AppRegistrationOwnerOrganization,
|
|
Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example",
|
|
}
|
|
}
|
|
|
|
func authenticatedAppForImport(
|
|
t *testing.T,
|
|
request AppRegistrationImportRequest,
|
|
) AuthenticatedApp {
|
|
t.Helper()
|
|
submission, err := BuildAppRegistrationManifest(AppRegistrationManifestRequest{
|
|
RegistrationID: request.RegistrationID, OwnerType: ManifestOwnerOrganization,
|
|
OwnerLogin: request.OwnerLogin, PublicBaseURL: request.PublicBaseURL,
|
|
Visibility: request.Visibility,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manifest := submission.Manifest
|
|
return AuthenticatedApp{
|
|
ID: request.AppID, ClientID: request.ClientID, Slug: request.Slug,
|
|
Name: request.DisplayName, OwnerLogin: request.OwnerLogin, OwnerType: string(request.OwnerType),
|
|
ExternalURL: manifest.URL,
|
|
Permissions: manifest.DefaultPermissions, Events: manifest.DefaultEvents,
|
|
}
|
|
}
|
|
|
|
func importPublicResolver() PublicGitHubBaseURLResolver {
|
|
return manifestResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
|
return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil
|
|
})
|
|
}
|