package github import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "net/netip" "net/url" "reflect" "strings" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/kandev/kandev/internal/auth/authn" ) type blockingAppRegistrationRuntime struct { applyStarted chan struct{} releaseApply chan struct{} mu sync.Mutex active bool } type failingAppRegistrationRuntime struct { mu sync.Mutex active bool applyErr error invalidated bool } func (r *failingAppRegistrationRuntime) ValidateAppRegistrationRuntime(ResolvedDeploymentAppConfig) error { return nil } func (r *failingAppRegistrationRuntime) ApplyAppRegistrationRuntime(ResolvedDeploymentAppConfig) error { return r.applyErr } func (r *failingAppRegistrationRuntime) InvalidateAppRegistrationRuntime(string, int64) { r.mu.Lock() r.active = false r.invalidated = true r.mu.Unlock() } func (r *failingAppRegistrationRuntime) state() (bool, bool) { r.mu.Lock() defer r.mu.Unlock() return r.active, r.invalidated } func (r *blockingAppRegistrationRuntime) ValidateAppRegistrationRuntime(ResolvedDeploymentAppConfig) error { return nil } func (r *blockingAppRegistrationRuntime) ApplyAppRegistrationRuntime(ResolvedDeploymentAppConfig) error { close(r.applyStarted) <-r.releaseApply r.mu.Lock() r.active = true r.mu.Unlock() return nil } func (r *blockingAppRegistrationRuntime) InvalidateAppRegistrationRuntime(string, int64) { r.mu.Lock() r.active = false r.mu.Unlock() } func (r *blockingAppRegistrationRuntime) isActive() bool { r.mu.Lock() defer r.mu.Unlock() return r.active } func setupAppRegistrationController(t *testing.T) (*gin.Engine, *Service, *Store) { t.Helper() gin.SetMode(gin.TestMode) store := newTestStore(t) secrets := newFakeConnectionSecrets() service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) service.SetConnectionSecretStore(secrets) repository := NewAppRegistrationRepository(store, secrets) conversion := testManifestConversion(t, "acme", "Organization") service.appRegistrationLifecycle = NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ Repository: repository, Store: store, Runtime: service, Converter: manifestConverterFunc(func(context.Context, string) (ManifestConversionResult, error) { return conversion, nil }), Resolver: manifestResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil }), Importer: NewAppRegistrationImporter(repository, manifestResolverFunc(func( context.Context, string, string, ) ([]netip.Addr, error) { return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil }), func(int64, []byte) (AppRegistrationVerifier, error) { return appRegistrationVerifierFunc(func(context.Context) (AuthenticatedApp, error) { return AuthenticatedApp{ ID: 101, ClientID: "Iv1.client", Slug: "kandev-app", OwnerLogin: "acme", OwnerType: "Organization", ExternalURL: "https://kandev.example", Permissions: conversion.Permissions, Events: conversion.Events, }, nil }), nil }), Random: strings.NewReader(strings.Repeat("h", oauthRandomBytes*3)), }) router := gin.New() NewController(service, testLogger(t)).RegisterHTTPRoutes(router) return router, service, store } func TestHTTPAppRegistrationManifestIsRouteBoundAndReturnsToWorkspace(t *testing.T) { router, _, store := setupAppRegistrationController(t) seedConnectionWorkspaces(t, store, "workspace-1") start := serveRegistrationJSON(t, router, http.MethodPost, "/api/v1/github/app/registrations/manifest/start", `{ "workspace_id":"workspace-1","display_name":"Work automation", "owner_type":"organization","owner_login":"acme","visibility":"private", "public_base_url":"https://kandev.example" }`) if start.Code != http.StatusOK { t.Fatalf("start = %d %s", start.Code, start.Body.String()) } var started AppRegistrationManifestStart if err := json.Unmarshal(start.Body.Bytes(), &started); err != nil || started.RegistrationID == "" { t.Fatalf("start response = %s, error = %v", start.Body.String(), err) } registrationURL, err := url.Parse(started.RegistrationURL) if err != nil { t.Fatalf("registration URL = %q: %v", started.RegistrationURL, err) } if registrationURL.Query().Get("state") != started.State { t.Fatalf("registration URL state = %q, want %q", registrationURL.Query().Get("state"), started.State) } redirectURL, err := url.Parse(started.Manifest.RedirectURL) if err != nil { t.Fatalf("manifest redirect URL = %q: %v", started.Manifest.RedirectURL, err) } if redirectURL.RawQuery != "" { t.Fatalf("manifest redirect URL query = %q, want empty", redirectURL.RawQuery) } wrong := httptest.NewRecorder() router.ServeHTTP(wrong, httptest.NewRequest(http.MethodGet, "/api/v1/github/app/registrations/"+uuid.NewString()+ "/manifest/callback?state="+started.State+"&code=one-time-code", nil)) assertPrivateGitHubRedirect(t, wrong) flowHash := stateDigest(started.State) flow, err := store.GetDeploymentAppRegistrationFlow(context.Background(), flowHash) if err != nil || flow == nil || flow.ConsumedAt != nil { t.Fatalf("wrong route consumed flow: flow=%+v err=%v", flow, err) } callback := httptest.NewRecorder() router.ServeHTTP(callback, httptest.NewRequest(http.MethodGet, "/api/v1/github/app/registrations/"+started.RegistrationID+ "/manifest/callback?state="+started.State+"&code=one-time-code", nil)) assertPrivateGitHubRedirect(t, callback) location := callback.Header().Get("Location") if !strings.Contains(location, "workspace_id=workspace-1") || !strings.Contains(location, "github_result=app_registered") { t.Fatalf("callback location = %q", location) } catalog := httptest.NewRecorder() router.ServeHTTP(catalog, httptest.NewRequest(http.MethodGet, "/api/v1/github/app/registrations?workspace_id=workspace-1", nil)) if catalog.Code != http.StatusOK || !strings.Contains(catalog.Body.String(), `"display_name":"Work automation"`) { t.Fatalf("catalog = %d %s", catalog.Code, catalog.Body.String()) } for _, secret := range []string{"generated-client", "generated-webhook", "PRIVATE KEY"} { if strings.Contains(catalog.Body.String()+location, secret) { t.Fatalf("registration response leaked %q", secret) } } } func TestHTTPAppRegistrationManifestCallbackAllowsNonDefaultAdmin(t *testing.T) { router, _, store := setupAppRegistrationController(t) seedConnectionWorkspaces(t, store, "workspace-1") startRequest := httptest.NewRequest(http.MethodPost, "/api/v1/github/app/registrations/manifest/start", strings.NewReader(`{ "workspace_id":"workspace-1","display_name":"Work automation", "owner_type":"organization","owner_login":"acme","visibility":"private", "public_base_url":"https://kandev.example" }`)) startRequest.Header.Set("Content-Type", "application/json") startRequest = startRequest.WithContext(authn.WithIdentity(startRequest.Context(), authn.Identity{ UserID: "admin-1", Role: authn.RoleAdmin, })) start := httptest.NewRecorder() router.ServeHTTP(start, startRequest) if start.Code != http.StatusOK { t.Fatalf("start = %d %s", start.Code, start.Body.String()) } var started AppRegistrationManifestStart if err := json.Unmarshal(start.Body.Bytes(), &started); err != nil || started.RegistrationID == "" { t.Fatalf("start response = %s, error = %v", start.Body.String(), err) } callback := httptest.NewRecorder() router.ServeHTTP(callback, httptest.NewRequest(http.MethodGet, "/api/v1/github/app/registrations/"+started.RegistrationID+ "/manifest/callback?state="+started.State+"&code=one-time-code", nil)) assertPrivateGitHubRedirect(t, callback) if location := callback.Header().Get("Location"); !strings.Contains(location, "workspace_id=workspace-1") || !strings.Contains(location, "github_result=app_registered") { t.Fatalf("callback location = %q", location) } } func TestHTTPAppRegistrationCatalogRequiresAdminIdentity(t *testing.T) { router, _, store := setupAppRegistrationController(t) seedConnectionWorkspaces(t, store, "workspace-1") memberReq := httptest.NewRequest(http.MethodGet, "/api/v1/github/app/registrations?workspace_id=workspace-1", nil) memberReq = memberReq.WithContext(authn.WithIdentity(memberReq.Context(), authn.Identity{ UserID: "member-1", Role: authn.RoleMember, })) member := httptest.NewRecorder() router.ServeHTTP(member, memberReq) if member.Code != http.StatusForbidden { t.Fatalf("member status = %d, want %d: %s", member.Code, http.StatusForbidden, member.Body.String()) } adminReq := httptest.NewRequest(http.MethodGet, "/api/v1/github/app/registrations?workspace_id=workspace-1", nil) adminReq = adminReq.WithContext(authn.WithIdentity(adminReq.Context(), authn.Identity{ UserID: "admin-1", Role: authn.RoleAdmin, })) admin := httptest.NewRecorder() router.ServeHTTP(admin, adminReq) if admin.Code != http.StatusOK { t.Fatalf("admin status = %d, want %d: %s", admin.Code, http.StatusOK, admin.Body.String()) } } func TestAppRegistrationDeleteWaitsForRuntimePublication(t *testing.T) { store := newTestStore(t) seedConnectionWorkspaces(t, store, "workspace-1") secrets := newFakeConnectionSecrets() repository := NewAppRegistrationRepository(store, secrets) runtime := &blockingAppRegistrationRuntime{ applyStarted: make(chan struct{}), releaseApply: make(chan struct{}), } conversion := testManifestConversion(t, "acme", "Organization") lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ Repository: repository, Store: store, Runtime: runtime, Converter: manifestConverterFunc(func(context.Context, string) (ManifestConversionResult, error) { return conversion, nil }), Resolver: manifestResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil }), Random: strings.NewReader(strings.Repeat("r", oauthRandomBytes*3)), }) ctx := context.Background() started, err := lifecycle.StartManifest(ctx, DefaultUserID, AppRegistrationManifestStartRequest{ WorkspaceID: "workspace-1", DisplayName: "Concurrent App", OwnerType: ManifestOwnerOrganization, OwnerLogin: "acme", Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example", }) if err != nil { t.Fatal(err) } completeResult := make(chan error, 1) go func() { _, completeErr := lifecycle.CompleteManifest(ctx, started.RegistrationID, AppRegistrationManifestCallback{ State: started.State, Code: "one-time-code", }) completeResult <- completeErr }() <-runtime.applyStarted if registration, getErr := store.GetAppRegistration(ctx, started.RegistrationID); getErr != nil || registration == nil { t.Fatalf("registration before runtime publication = %+v, err %v", registration, getErr) } deleteResult := make(chan error, 1) go func() { deleteResult <- lifecycle.Delete(ctx, DefaultUserID, started.RegistrationID) }() select { case deleteErr := <-deleteResult: t.Fatalf("Delete() completed before runtime publication: %v", deleteErr) case <-time.After(100 * time.Millisecond): } close(runtime.releaseApply) if err := <-completeResult; err != nil { t.Fatalf("CompleteManifest(): %v", err) } if err := <-deleteResult; err != nil { t.Fatalf("Delete(): %v", err) } if runtime.isActive() { t.Fatal("deleted registration runtime remains active") } registration, err := store.GetAppRegistration(ctx, started.RegistrationID) if err != nil || registration != nil { t.Fatalf("registration after delete = %+v, err %v", registration, err) } } func TestAppRegistrationActivationSurfacesSanitizedCleanupFailure(t *testing.T) { store := newTestStore(t) seedConnectionWorkspaces(t, store, "workspace-1") secrets := newFakeConnectionSecrets() secrets.deleteErr = errors.New("cleanup detail containing private-key") repository := NewAppRegistrationRepository(store, secrets) runtime := &failingAppRegistrationRuntime{applyErr: errors.New("activation detail containing client-secret")} conversion := testManifestConversion(t, "acme", "Organization") lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ Repository: repository, Store: store, Runtime: runtime, Converter: manifestConverterFunc(func(context.Context, string) (ManifestConversionResult, error) { return conversion, nil }), Resolver: manifestResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil }), Random: strings.NewReader(strings.Repeat("s", oauthRandomBytes*3)), }) ctx := context.Background() started, err := lifecycle.StartManifest(ctx, DefaultUserID, AppRegistrationManifestStartRequest{ WorkspaceID: "workspace-1", DisplayName: "Cleanup failure App", OwnerType: ManifestOwnerOrganization, OwnerLogin: "acme", Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example", }) if err != nil { t.Fatal(err) } _, err = lifecycle.CompleteManifest(ctx, started.RegistrationID, AppRegistrationManifestCallback{ State: started.State, Code: "one-time-code", }) if err == nil || !strings.Contains(err.Error(), "could not be activated") || !strings.Contains(err.Error(), "could not be cleaned up") { t.Fatalf("CompleteManifest() error = %v", err) } for _, sensitive := range []string{"private-key", "client-secret"} { if strings.Contains(err.Error(), sensitive) { t.Fatalf("CompleteManifest() error leaked %q: %v", sensitive, err) } } } func TestAppRegistrationDeleteFailureInvalidatesRuntime(t *testing.T) { store := newTestStore(t) seedConnectionWorkspaces(t, store, "workspace-1") secrets := newFakeConnectionSecrets() repository := NewAppRegistrationRepository(store, secrets) registration := newAppRegistration("registration-delete-runtime", 101, "Work", time.Now().UTC()) if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", }); err != nil { t.Fatal(err) } secrets.deleteErr = errors.New("delete unavailable") secrets.deleteErrKey = registration.CredentialSecretID secrets.deleteThenError = true secrets.setErr = errors.New("restore unavailable") secrets.setErrKey = registration.CredentialSecretID runtime := &failingAppRegistrationRuntime{active: true} lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ Repository: repository, Store: store, Runtime: runtime, }) err := lifecycle.Delete(context.Background(), DefaultUserID, registration.ID) if !errors.Is(err, ErrAppRegistrationCredentialCleanup) { t.Fatalf("Delete() error = %v, want credential cleanup error", err) } active, invalidated := runtime.state() if active || !invalidated { t.Fatalf("runtime after failed delete: active=%v invalidated=%v", active, invalidated) } stored, getErr := store.GetAppRegistration(context.Background(), registration.ID) if getErr != nil || stored == nil || stored.Status != AppRegistrationStatusInvalid { t.Fatalf("registration after failed credential restoration = %+v, err %v", stored, getErr) } } func TestAppRegistrationDeleteFailurePreservesRuntimeAfterCredentialRollback(t *testing.T) { store := newTestStore(t) seedConnectionWorkspaces(t, store, "workspace-1") secrets := newFakeConnectionSecrets() repository := NewAppRegistrationRepository(store, secrets) registration := newAppRegistration("registration-delete-runtime-rollback", 101, "Work", time.Now().UTC()) if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", }); err != nil { t.Fatal(err) } secrets.deleteErr = errors.New("delete unavailable") secrets.deleteErrKey = registration.CredentialSecretID runtime := &failingAppRegistrationRuntime{active: true} lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ Repository: repository, Store: store, Runtime: runtime, }) err := lifecycle.Delete(context.Background(), DefaultUserID, registration.ID) if !errors.Is(err, ErrAppRegistrationCredentialCleanup) { t.Fatalf("Delete() error = %v, want credential cleanup error", err) } active, invalidated := runtime.state() if !active || invalidated { t.Fatalf("runtime after compensated delete: active=%v invalidated=%v", active, invalidated) } stored, getErr := store.GetAppRegistration(context.Background(), registration.ID) if getErr != nil || stored == nil || stored.Status != AppRegistrationStatusActive { t.Fatalf("registration after compensated delete = %+v, err %v", stored, getErr) } } func TestAppRegistrationDeleteFailureInvalidatesRuntimeWhenCredentialAndStatusRestoreFail(t *testing.T) { store := newTestStore(t) seedConnectionWorkspaces(t, store, "workspace-1") secrets := newFakeConnectionSecrets() repository := NewAppRegistrationRepository(store, secrets) registration := newAppRegistration("registration-delete-double-failure", 101, "Work", time.Now().UTC()) if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret", }); err != nil { t.Fatal(err) } if _, err := store.db.Exec(` CREATE TRIGGER fail_app_registration_invalidation BEFORE UPDATE OF status ON github_app_registrations WHEN NEW.status = 'invalid' BEGIN SELECT RAISE(FAIL, 'invalidation unavailable'); END`); err != nil { t.Fatal(err) } secrets.deleteErr = errors.New("delete unavailable") secrets.deleteErrKey = registration.CredentialSecretID secrets.deleteThenError = true secrets.setErr = errors.New("restore unavailable") secrets.setErrKey = registration.CredentialSecretID runtime := &failingAppRegistrationRuntime{active: true} lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ Repository: repository, Store: store, Runtime: runtime, }) err := lifecycle.Delete(context.Background(), DefaultUserID, registration.ID) if !errors.Is(err, ErrAppRegistrationCredentialCleanup) || !errors.Is(err, ErrAppRegistrationDeletionFailed) { t.Fatalf("Delete() error = %v, want cleanup and deletion errors", err) } active, invalidated := runtime.state() if active || !invalidated { t.Fatalf("runtime after double failure: active=%v invalidated=%v", active, invalidated) } stored, getErr := store.GetAppRegistration(context.Background(), registration.ID) if getErr != nil || stored == nil || stored.Status != AppRegistrationStatusActive { t.Fatalf("metadata after failed invalidation = %+v, err %v", stored, getErr) } if _, _, loadErr := repository.LoadRegistration(context.Background(), registration.ID); loadErr == nil { t.Fatal("registration credentials unexpectedly remained readable") } } func TestAppRegistrationDurableCheckInvalidatesSupersededRuntimeGeneration(t *testing.T) { store := newTestStore(t) seedConnectionWorkspaces(t, store, "workspace-1") secrets := newFakeConnectionSecrets() repository := NewAppRegistrationRepository(store, secrets) registration := newAppRegistration("registration-generation-change", 101, "Work", time.Now().UTC()) if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ PrivateKey: "private-key-1", ClientSecret: "client-secret-1", WebhookSecret: "webhook-secret-1", }); err != nil { t.Fatal(err) } previous := *registration registration.CredentialGeneration++ if err := repository.SaveRegistration(context.Background(), registration, DeploymentAppCredentials{ PrivateKey: "private-key-2", ClientSecret: "client-secret-2", WebhookSecret: "webhook-secret-2", }); err != nil { t.Fatal(err) } runtime := &failingAppRegistrationRuntime{active: true} lifecycle := NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{ Repository: repository, Store: store, Runtime: runtime, }) lifecycle.invalidateRuntimeForUnusableRegistration(context.Background(), &previous) active, invalidated := runtime.state() if active || !invalidated { t.Fatalf("superseded runtime state: active=%v invalidated=%v", active, invalidated) } } func TestHTTPAppRegistrationDuplicateImportReturnsExistingID(t *testing.T) { router, _, store := setupAppRegistrationController(t) seedConnectionWorkspaces(t, store, "workspace-1") existing := newAppRegistration("registration-existing", 101, "Existing", time.Now().UTC()) if err := store.UpsertDeploymentAppRegistration(context.Background(), existing); err != nil { t.Fatal(err) } prepare := serveRegistrationJSON(t, router, http.MethodPost, "/api/v1/github/app/registrations/import/prepare", `{"workspace_id":"workspace-1","public_base_url":"https://kandev.example"}`) if prepare.Code != http.StatusCreated { t.Fatalf("prepare = %d %s", prepare.Code, prepare.Body.String()) } var preparation AppRegistrationImportPreparationResult if err := json.Unmarshal(prepare.Body.Bytes(), &preparation); err != nil { t.Fatal(err) } response := serveRegistrationJSON(t, router, http.MethodPost, "/api/v1/github/app/registrations/import", `{ "registration_id":"`+preparation.RegistrationID+`","workspace_id":"workspace-1","display_name":"Duplicate","github_host":"github.com", "app_id":101,"client_id":"Iv1.client","client_secret":"client-secret", "private_key":"private-key","webhook_secret":"webhook-secret","slug":"kandev-app", "owner_login":"acme","owner_type":"Organization","visibility":"private", "public_base_url":"https://kandev.example" }`) if response.Code != http.StatusConflict || !strings.Contains(response.Body.String(), `"code":"github_app_already_registered"`) || !strings.Contains(response.Body.String(), `"existing_registration_id":"registration-existing"`) { t.Fatalf("duplicate import = %d %s", response.Code, response.Body.String()) } for _, secret := range []string{"client-secret", "private-key", "webhook-secret"} { if strings.Contains(response.Body.String(), secret) { t.Fatalf("duplicate response leaked %q", secret) } } } func TestHTTPAppRegistrationImportPreparationIsSingleUse(t *testing.T) { router, _, store := setupAppRegistrationController(t) seedConnectionWorkspaces(t, store, "workspace-1") prepare := serveRegistrationJSON(t, router, http.MethodPost, "/api/v1/github/app/registrations/import/prepare", `{"workspace_id":"workspace-1","public_base_url":"https://KANDEV.example:443/"}`) if prepare.Code != http.StatusCreated || prepare.Header().Get("Cache-Control") != "no-store" { t.Fatalf("prepare = %d headers=%v body=%s", prepare.Code, prepare.Header(), prepare.Body.String()) } var preparation AppRegistrationImportPreparationResult if err := json.Unmarshal(prepare.Body.Bytes(), &preparation); err != nil { t.Fatal(err) } base := "https://kandev.example/api/v1/github/app/registrations/" + preparation.RegistrationID if preparation.PublicBaseURL != "https://kandev.example" || preparation.ManifestCallbackURL != base+"/manifest/callback" || preparation.InstallCallbackURL != base+"/install/callback" || preparation.PersonalCallbackURL != base+"/personal/callback" || preparation.WebhookURL != base+"/webhook" || preparation.SetupURL != "" || preparation.Permissions["contents"] != "write" || !reflect.DeepEqual(preparation.Events, []string{"push", "check_run"}) { t.Fatalf("preparation = %+v", preparation) } _, privateKey := testAppPrivateKey(t) request := AppRegistrationImportRequest{ RegistrationID: preparation.RegistrationID, WorkspaceID: "workspace-1", DisplayName: "Existing App", GitHubHost: defaultGitHubAppHost, AppID: 101, ClientID: "Iv1.client", ClientSecret: "client-secret", PrivateKey: string(privateKey), WebhookSecret: "webhook-secret", Slug: "kandev-app", OwnerLogin: "acme", OwnerType: AppRegistrationOwnerOrganization, Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: preparation.PublicBaseURL, } body, err := json.Marshal(request) if err != nil { t.Fatal(err) } imported := serveRegistrationJSON(t, router, http.MethodPost, "/api/v1/github/app/registrations/import", string(body)) if imported.Code != http.StatusCreated || !strings.Contains(imported.Body.String(), preparation.RegistrationID) { t.Fatalf("import = %d %s", imported.Code, imported.Body.String()) } replayed := serveRegistrationJSON(t, router, http.MethodPost, "/api/v1/github/app/registrations/import", string(body)) if replayed.Code != http.StatusBadRequest || !strings.Contains(replayed.Body.String(), `"code":"github_app_import_preparation_invalid"`) { t.Fatalf("replay = %d %s", replayed.Code, replayed.Body.String()) } for _, secret := range []string{request.ClientSecret, request.PrivateKey, request.WebhookSecret} { if strings.Contains(replayed.Body.String(), secret) { t.Fatalf("replay response leaked credential %q", secret) } } } func TestHTTPAppRegistrationImportWithoutPreparationFailsWithoutSecretLeak(t *testing.T) { router, _, store := setupAppRegistrationController(t) seedConnectionWorkspaces(t, store, "workspace-1") _, privateKey := testAppPrivateKey(t) request := AppRegistrationImportRequest{ RegistrationID: uuid.NewString(), WorkspaceID: "workspace-1", DisplayName: "Unprepared App", GitHubHost: defaultGitHubAppHost, AppID: 101, ClientID: "Iv1.client", ClientSecret: "unprepared-client-secret", PrivateKey: string(privateKey), WebhookSecret: "unprepared-webhook-secret", Slug: "kandev-app", OwnerLogin: "acme", OwnerType: AppRegistrationOwnerOrganization, Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example", } body, err := json.Marshal(request) if err != nil { t.Fatal(err) } response := serveRegistrationJSON(t, router, http.MethodPost, "/api/v1/github/app/registrations/import", string(body)) if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":"github_app_import_preparation_invalid"`) { t.Fatalf("unprepared import = %d %s", response.Code, response.Body.String()) } for _, secret := range []string{request.ClientSecret, request.PrivateKey, request.WebhookSecret} { if strings.Contains(response.Body.String(), secret) { t.Fatalf("unprepared response leaked credential %q", secret) } } } func TestAppRegistrationCatalogSharedCountsWorkspaceBindingsOnly(t *testing.T) { _, service, store := setupAppRegistrationController(t) seedConnectionWorkspaces(t, store, "workspace-1", "workspace-2") ctx := context.Background() now := time.Now().UTC() registration := newAppRegistration("registration-shared", 101, "Shared App", now) if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil { t.Fatal(err) } installationID := int64(42) connection := func(workspaceID string) *WorkspaceConnection { return &WorkspaceConnection{ WorkspaceID: workspaceID, Source: ConnectionSourceGitHubAppInstallation, GitHubHost: defaultGitHubHost, InstallationID: &installationID, InstallationAccountLogin: "acme", InstallationAccountType: "Organization", AppRegistrationID: registration.ID, Status: ConnectionStatusActive, CredentialGeneration: 1, } } if err := store.UpsertWorkspaceConnection(ctx, connection("workspace-1")); err != nil { t.Fatal(err) } if err := store.UpsertUserConnection(ctx, &UserConnection{ WorkspaceID: "workspace-1", UserID: DefaultUserID, AppRegistrationID: registration.ID, GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1, }); err != nil { t.Fatal(err) } catalog, err := service.ListAppRegistrationCatalog(ctx, DefaultUserID, "workspace-1") if err != nil || len(catalog.Registrations) != 1 { t.Fatalf("single-workspace catalog = %+v, err %v", catalog, err) } item := catalog.Registrations[0] if item.BindingCount != 2 || item.WorkspaceBindingCount != 1 || item.Shared { t.Fatalf("single-workspace item = %+v", item) } if err := store.UpsertWorkspaceConnection(ctx, connection("workspace-2")); err != nil { t.Fatal(err) } catalog, err = service.ListAppRegistrationCatalog(ctx, DefaultUserID, "workspace-1") if err != nil || len(catalog.Registrations) != 1 { t.Fatalf("shared catalog = %+v, err %v", catalog, err) } item = catalog.Registrations[0] if item.BindingCount != 3 || item.WorkspaceBindingCount != 2 || !item.Shared { t.Fatalf("shared item = %+v", item) } } func TestLegacySingletonGitHubAppRoutesAreRemoved(t *testing.T) { router, _, _ := setupAppRegistrationController(t) for _, route := range []string{ "/api/v1/github/app/registration", "/api/v1/github/app/registration/callback", "/api/v1/github/app/callback", "/api/v1/github/personal/callback", } { response := httptest.NewRecorder() router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, route, nil)) if response.Code != http.StatusNotFound { t.Errorf("GET %s = %d, want 404", route, response.Code) } } } func TestGitHubAuthCallbackErrorsReturnToInitiatingWorkspace(t *testing.T) { router, _, store := setupAppRegistrationController(t) seedConnectionWorkspaces(t, store, "workspace-1") registration := newAppRegistration("registration-work", 101, "Work", time.Now().UTC()) if err := store.UpsertDeploymentAppRegistration(context.Background(), registration); err != nil { t.Fatal(err) } for _, test := range []struct { name string kind AuthFlowKind path string }{ {name: "installation", kind: AuthFlowKindAppInstallation, path: "/api/v1/github/app/registrations/wrong-registration/install/callback?installation_id=42"}, {name: "personal", kind: AuthFlowKindPersonal, path: "/api/v1/github/app/registrations/wrong-registration/personal/callback?code=oauth-code"}, } { t.Run(test.name, func(t *testing.T) { flows := NewOAuthFlowManager(store) flows.random = strings.NewReader(strings.Repeat(test.name, oauthRandomBytes*4)) started, err := flows.Start(context.Background(), OAuthFlowRequest{ WorkspaceID: "workspace-1", UserID: DefaultUserID, AppRegistrationID: registration.ID, Kind: test.kind, }) if err != nil { t.Fatal(err) } response := httptest.NewRecorder() router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path+"&state="+started.State, nil)) assertPrivateGitHubRedirect(t, response) if location := response.Header().Get("Location"); !strings.Contains(location, "workspace_id=workspace-1") || !strings.Contains(location, "github_result=github_not_configured") { t.Fatalf("callback location = %q", location) } flow, err := store.GetAuthFlow(context.Background(), stateDigest(started.State)) if err != nil || flow == nil || flow.ConsumedAt != nil { t.Fatalf("wrong route consumed flow: flow=%+v err=%v", flow, err) } }) } } func serveRegistrationJSON( t *testing.T, router http.Handler, method, path, body string, ) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(method, path, strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() router.ServeHTTP(response, request) return response } func assertPrivateGitHubRedirect(t *testing.T, response *httptest.ResponseRecorder) { t.Helper() if response.Code != http.StatusSeeOther || response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Referrer-Policy") != "no-referrer" { t.Fatalf("callback = %d headers=%v body=%s", response.Code, response.Header(), response.Body.String()) } if location := response.Header().Get("Location"); strings.Contains(location, "state=") || strings.Contains(location, "code=") { t.Fatalf("callback leaked provider parameters: %q", location) } }