Files
kandev/apps/backend/internal/github/app_registration_import_test.go
2026-08-12 11:13:05 +01:00

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
})
}