diff --git a/.github/workflows/dotcom-acceptance-tests.yaml b/.github/workflows/dotcom-acceptance-tests.yaml index 10004d8917..1a8e50a996 100644 --- a/.github/workflows/dotcom-acceptance-tests.yaml +++ b/.github/workflows/dotcom-acceptance-tests.yaml @@ -2,10 +2,11 @@ name: Acceptance Tests (github.com) on: workflow_dispatch: - # push: - # branches: - # - main - # - release-v* + push: + branches: + - main + - release-v* + # pull_request_target: pull_request: types: - opened @@ -23,17 +24,71 @@ concurrency: permissions: read-all jobs: + setup: + name: Setup + runs-on: ubuntu-latest + defaults: + run: + shell: bash + outputs: + fork: ${{ steps.check.outputs.fork }} + test: ${{ steps.check.outputs.test }} + environment: ${{ steps.check.outputs.environment }} + steps: + - name: Check + id: check + env: + GITHUB_HEAD_REPO: ${{ case(github.event_name == 'pull_request' || github.event_name == 'pull_request_target', github.event.pull_request.head.repo.full_name, github.repository) }} + GITHUB_BASE_REPO: ${{ case(github.event_name == 'pull_request' || github.event_name == 'pull_request_target', github.event.pull_request.base.repo.full_name, github.repository) }} + ACCTEST_LABEL_SET: ${{ contains(github.event.pull_request.labels.*.name, 'acctest') }} + run: | + set -euo pipefail + + fork="true" + test="false" + environment="acctest-dotcom-untrusted" + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]] || [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + fork="false" + test="true" + environment="acctest-dotcom" + echo "::notice::Running in ${GITHUB_EVENT_NAME} context, proceeding with tests" + else + if [[ "${GITHUB_HEAD_REPO}" == "${GITHUB_BASE_REPO}" ]]; then + fork="false" + test="true" + environment="acctest-dotcom" + echo "::notice::Running in ${GITHUB_EVENT_NAME} context from the base repository, proceeding with tests" + else + if [[ "${ACCTEST_LABEL_SET}" == "true" ]]; then + test="true" + echo "::warning::Running in ${GITHUB_EVENT_NAME} context from a fork, proceeding with tests as acctest label is set" + else + echo "::warning::Running in ${GITHUB_EVENT_NAME} context from a fork, skipping tests as acctest label is not set" + fi + fi + fi + + { + echo "test=${test}" + echo "environment=${environment}" + echo "fork=${fork}" + } >> "${GITHUB_OUTPUT}" + test: - name: Test ${{ matrix.mode }} - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || contains(github.event.pull_request.labels.*.name, 'acctest') + name: Test ${{ matrix.mode || 'Skipped' }} + needs: + - setup + if: needs.setup.outputs.test == 'true' runs-on: ubuntu-latest permissions: contents: read environment: - name: acctest-dotcom + name: ${{ needs.setup.outputs.environment }} + deployment: false strategy: matrix: - mode: [anonymous, individual, organization] # team, enterprise + mode: [organization] # anonymous, individual, team, enterprise fail-fast: true max-parallel: 1 defaults: @@ -44,16 +99,18 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check secrets - if: github.event_name == 'pull_request_target' + if: github.event_name == 'pull_request' || github.event_name == 'pull_request_target' env: - INPUT_ALLOWED_SECRETS: ${{ vars.DOTCOM_ACCEPTANCE_TESTS_ALLOWED_SECRETS || 'GH_TEST_TOKEN' }} INPUT_SECRETS: ${{ toJSON(secrets) }} + INPUT_ALLOWED_SECRETS: ${{ vars.GH_TEST_ALLOWED_SECRETS }} run: | set -eou pipefail - secret_keys="$(jq --raw-output --compact-output '[. | keys[] | select(test("^(?:(?:ACTIONS)|(?:actions)|(?:GITHUB)|(?:github)|(?:TEST)|(?:test))_") | not)] | sort | join(",")' <<<"${INPUT_SECRETS}")" - if [[ "${secret_keys}" != "${INPUT_ALLOWED_SECRETS}" ]]; then - echo "::error::Too many or too few secrets configured: ${secret_keys}" + allowed_secrets="$(jq --raw-input --raw-output --compact-output 'split(",")' <<<"${INPUT_ALLOWED_SECRETS}")" + + secret_keys="$(jq --raw-output --compact-output --argjson allowed "${allowed_secrets}" '[[. | to_entries[] | select(.value != "" and .value != "!NOSECRET!")] | from_entries | keys[] | ascii_upcase | select(test("^(?:(?:ACTIONS)|(?:GITHUB)|(?:TEST)|(?:GH_TEST))_") | not) | select((IN($allowed[]) | not))] | sort | join(",")' <<<"${INPUT_SECRETS}")" + if [[ -n "${secret_keys}" ]]; then + echo "::error::Unexpected secrets: ${secret_keys}" exit 1 fi @@ -61,16 +118,56 @@ jobs: id: credentials if: matrix.mode != 'anonymous' env: + MATRIX_MODE: ${{ matrix.mode }} + GH_TEST_APP_ID: ${{ vars.GH_TEST_APP_ID }} + GH_TEST_APP_INSTALLATION_ID: ${{ vars.GH_TEST_APP_INSTALLATION_ID }} + GH_TEST_APP_PEM: ${{ secrets.GH_TEST_APP_PEM }} GH_TEST_TOKEN: ${{ secrets.GH_TEST_TOKEN }} run: | set -eou pipefail - if [[ -z "${GH_TEST_TOKEN}" ]]; then - echo "::error::Missing credentials" - exit 1 + app_id="" + app_installation_id="" + app_pem="" + token="" + + if [[ "${MATRIX_MODE}" == "individual" ]]; then + if [[ -z "${GH_TEST_TOKEN}" ]]; then + echo "::error::Missing token" + exit 1 + fi + + token="${GH_TEST_TOKEN}" + else + if [[ -z "${GH_TEST_APP_ID}" ]]; then + echo "::error::Missing app id" + exit 1 + fi + + if [[ -z "${GH_TEST_APP_INSTALLATION_ID}" ]]; then + echo "::error::Missing app installation id" + exit 1 + fi + + if [[ -z "${GH_TEST_APP_PEM}" ]]; then + echo "::error::Missing app pem" + exit 1 + fi + + app_id="${GH_TEST_APP_ID}" + app_installation_id="${GH_TEST_APP_INSTALLATION_ID}" + app_pem="${GH_TEST_APP_PEM}" fi - echo "token=${GH_TEST_TOKEN}" >> "${GITHUB_OUTPUT}" + { + echo "app_id=${app_id}" + echo "app_installation_id=${app_installation_id}" + printf 'app_pem<> "${GITHUB_OUTPUT}" - name: Set-up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 @@ -101,11 +198,15 @@ jobs: TF_ACC_TERRAFORM_PATH: ${{ steps.tf.outputs.path }} TF_ACC: "1" TF_LOG: WARN + GITHUB_WRITE_DELAY_MS: "0" + GITHUB_APP_ID: ${{ steps.credentials.outputs.app_id }} + GITHUB_APP_INSTALLATION_ID: ${{ steps.credentials.outputs.app_installation_id }} + GITHUB_APP_PEM_FILE: ${{ steps.credentials.outputs.app_pem }} GITHUB_TOKEN: ${{ steps.credentials.outputs.token }} GITHUB_BASE_URL: https://api.github.com/ - GITHUB_OWNER: ${{ (matrix.mode == 'individual' && vars.GH_TEST_LOGIN) || (matrix.mode == 'organization' && vars.GH_TEST_ORG_NAME) || '' }} - GITHUB_USERNAME: ${{ vars.GH_TEST_LOGIN }} - GITHUB_ENTERPRISE_SLUG: ${{ vars.GH_TEST_ENTERPRISE_SLUG }} + GITHUB_OWNER: ${{ case(matrix.mode == 'anonymous', '', matrix.mode == 'individual', vars.GH_TEST_LOGIN, vars.GH_TEST_ORG_NAME) }} + GITHUB_USERNAME: ${{ case(matrix.mode == 'individual', vars.GH_TEST_LOGIN, '') }} + GITHUB_ENTERPRISE_SLUG: ${{ case(matrix.mode == 'enterprise', vars.GH_TEST_ENTERPRISE_SLUG, '') }} GH_TEST_AUTH_MODE: ${{ matrix.mode }} GH_TEST_USER_REPOSITORY: ${{ vars.GH_TEST_USER_REPOSITORY }} GH_TEST_ORG_USER: ${{ vars.GH_TEST_ORG_USER }} @@ -128,7 +229,7 @@ jobs: check: name: Check DotCom Acceptance Tests - if: always() && github.event_name == 'pull_request' + if: always() && (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') needs: - test runs-on: ubuntu-latest diff --git a/.github/workflows/ghes-acceptance-tests.yaml b/.github/workflows/ghes-acceptance-tests.yaml index c5f0aa0b47..348738e901 100644 --- a/.github/workflows/ghes-acceptance-tests.yaml +++ b/.github/workflows/ghes-acceptance-tests.yaml @@ -2,6 +2,10 @@ name: Acceptance Tests (GHES) on: workflow_dispatch: + # push: + # branches: + # - main + # - release-v* # pull_request_target: # types: # - opened @@ -26,6 +30,7 @@ jobs: contents: read environment: name: acctest-ghes + deployment: false defaults: run: shell: bash diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bec9405dc6..51f5bfdc79 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -25,6 +25,7 @@ jobs: id-token: write environment: name: release + deployment: false defaults: run: shell: bash diff --git a/github/acc_test.go b/github/acc_test.go index eb7b5644ef..f217ba8606 100644 --- a/github/acc_test.go +++ b/github/acc_test.go @@ -37,10 +37,13 @@ type testAccConfig struct { baseURL *url.URL // Auth configuration - authMode testMode - owner string - username string - token string + authMode testMode + owner string + username string + token string + appID string + appInstallationID string + appPEM string // Enterprise configuration enterpriseSlug string @@ -112,13 +115,11 @@ func TestMain(m *testing.M) { } config := testAccConfig{ - baseURL: baseURL, - authMode: authMode, - testPublicRepository: "terraform-provider-github", - testPublicRepositoryOwner: "integrations", - testPublicReleaseId: 186531906, - // The terraform-provider-github_6.4.0_manifest.json asset ID from - // https://github.com/integrations/terraform-provider-github/releases/tag/v6.4.0 + baseURL: baseURL, + authMode: authMode, + testPublicRepository: "terraform-provider-github", + testPublicRepositoryOwner: "integrations", + testPublicReleaseId: 186531906, // The terraform-provider-github_6.4.0_manifest.json asset ID from https://github.com/integrations/terraform-provider-github/releases/tag/v6.4.0 testPublicRelaseAssetId: "207956097", testPublicRelaseAssetName: "terraform-provider-github_6.4.0_manifest.json", testPublicReleaseAssetContent: "{\n \"version\": 1,\n \"metadata\": {\n \"protocol_versions\": [\n \"5.0\"\n ]\n }\n}", @@ -135,30 +136,38 @@ func TestMain(m *testing.M) { testExternalUser2: os.Getenv("GH_TEST_EXTERNAL_USER2"), testAdvancedSecurity: os.Getenv("GH_TEST_ADVANCED_SECURITY") == "true", testRepositoryVisibility: "public", - enterpriseIsEMU: authMode == enterprise && os.Getenv("GH_TEST_ENTERPRISE_IS_EMU") == "true", } if config.authMode != anonymous { config.owner = os.Getenv("GITHUB_OWNER") config.username = os.Getenv("GITHUB_USERNAME") config.token = os.Getenv("GITHUB_TOKEN") + config.appID = os.Getenv("GITHUB_APP_ID") + config.appInstallationID = os.Getenv("GITHUB_APP_INSTALLATION_ID") + config.appPEM = os.Getenv("GITHUB_APP_PEM_FILE") if len(config.owner) == 0 { fmt.Println("GITHUB_OWNER environment variable not set") os.Exit(1) } - if len(config.username) == 0 { + if config.authMode == individual && len(config.username) == 0 { fmt.Println("GITHUB_USERNAME environment variable not set") os.Exit(1) } - if len(config.token) == 0 { - fmt.Println("GITHUB_TOKEN environment variable not set") + if len(config.token) == 0 && (len(config.appID) == 0 || len(config.appInstallationID) == 0 || len(config.appPEM) == 0) { + fmt.Println("authentication not configured") os.Exit(1) } } + if config.authMode != anonymous && config.authMode != individual { + if i, err := strconv.Atoi(os.Getenv("GH_TEST_ORG_APP_INSTALLATION_ID")); err == nil { + config.testOrgAppInstallationId = i + } + } + if config.authMode == enterprise { config.enterpriseSlug = os.Getenv("GITHUB_ENTERPRISE_SLUG") @@ -167,9 +176,12 @@ func TestMain(m *testing.M) { os.Exit(1) } - i, err := strconv.Atoi(os.Getenv("GH_TEST_ENTERPRISE_EMU_GROUP_ID")) - if err == nil { - config.testEnterpriseEMUGroupId = i + if os.Getenv("GH_TEST_ENTERPRISE_IS_EMU") == "true" { + config.enterpriseIsEMU = true + + if i, err := strconv.Atoi(os.Getenv("GH_TEST_ENTERPRISE_EMU_GROUP_ID")); err == nil { + config.testEnterpriseEMUGroupId = i + } } if config.enterpriseIsEMU { @@ -177,11 +189,6 @@ func TestMain(m *testing.M) { } } - i, err := strconv.Atoi(os.Getenv("GH_TEST_ORG_APP_INSTALLATION_ID")) - if err == nil { - config.testOrgAppInstallationId = i - } - testAccConf = &config configureSweepers() @@ -189,11 +196,37 @@ func TestMain(m *testing.M) { resource.TestMain(m) } +func getTestAppToken() (string, error) { + if testAccConf.appID == "" || testAccConf.appInstallationID == "" || testAccConf.appPEM == "" { + return "", fmt.Errorf("app auth not configured") + } + + appToken, err := GenerateOAuthTokenFromApp(testAccConf.baseURL, testAccConf.appID, testAccConf.appInstallationID, testAccConf.appPEM) + if err != nil { + return "", err + } + + return appToken, nil +} + +func getTestToken() (string, error) { + if testAccConf.token != "" { + return testAccConf.token, nil + } + + return getTestAppToken() +} + func getTestMeta() (*Owner, error) { + token, err := getTestToken() + if err != nil { + return nil, fmt.Errorf("error getting test token: %w", err) + } + config := Config{ - Token: testAccConf.token, - Owner: testAccConf.owner, BaseURL: testAccConf.baseURL, + Owner: testAccConf.owner, + Token: token, } meta, err := config.Meta() @@ -292,6 +325,24 @@ func skipUnauthenticated(t *testing.T) { } } +func skipNoToken(t *testing.T) { + if testAccConf.authMode == anonymous { + t.Skip("Skipping as test mode not authenticated") + } + if testAccConf.token == "" { + t.Skip("Skipping as no token provided") + } +} + +func skipNoApp(t *testing.T) { + if testAccConf.authMode == anonymous { + t.Skip("Skipping as test mode not authenticated") + } + if testAccConf.appID == "" || testAccConf.appInstallationID == "" || testAccConf.appPEM == "" { + t.Skip("Skipping as app not configured") + } +} + func skipUnlessHasOrgs(t *testing.T) { if !slices.Contains(orgTestModes, testAccConf.authMode) { t.Skip("Skipping as test mode doesn't have orgs") diff --git a/github/config_test.go b/github/config_test.go index 1d88e231bc..ea3d692461 100644 --- a/github/config_test.go +++ b/github/config_test.go @@ -164,7 +164,7 @@ func TestAccConfigMeta(t *testing.T) { t.Fatalf("failed to parse test base URL: %s", err.Error()) } - t.Run("returns an anonymous client for the v3 REST API", func(t *testing.T) { + t.Run("rest_client_anonymous", func(t *testing.T) { config := Config{BaseURL: baseURL} meta, err := config.Meta() if err != nil { @@ -179,37 +179,17 @@ func TestAccConfigMeta(t *testing.T) { } }) - t.Run("returns a v3 REST API client to manage individual resources", func(t *testing.T) { - skipUnlessMode(t, individual) + t.Run("rest_client_authenticated", func(t *testing.T) { + skipUnauthenticated(t) - config := Config{ - Token: testAccConf.token, - BaseURL: baseURL, - } - meta, err := config.Meta() - if err != nil { - t.Fatalf("failed to return meta without error: %s", err.Error()) - } - - ctx := context.Background() - client := meta.(*Owner).v3client - _, _, err = client.Meta.Get(ctx) + token, err := getTestToken() if err != nil { - t.Fatalf("failed to validate returned client without error: %s", err.Error()) + t.Fatalf("failed to get test token: %s", err.Error()) } - }) - - t.Run("returns a v3 REST API client with max retries", func(t *testing.T) { - skipUnlessMode(t, individual) config := Config{ - Token: testAccConf.token, BaseURL: baseURL, - RetryableErrors: map[int]bool{ - 500: true, - 502: true, - }, - MaxRetries: 3, + Token: token, } meta, err := config.Meta() if err != nil { @@ -224,12 +204,17 @@ func TestAccConfigMeta(t *testing.T) { } }) - t.Run("returns a v4 GraphQL API client to manage individual resources", func(t *testing.T) { - skipUnlessMode(t, individual) + t.Run("graphql_client_authenticated", func(t *testing.T) { + skipUnauthenticated(t) + + token, err := getTestToken() + if err != nil { + t.Fatalf("failed to get test token: %s", err.Error()) + } config := Config{ - Token: testAccConf.token, BaseURL: baseURL, + Token: token, } meta, err := config.Meta() if err != nil { @@ -247,60 +232,6 @@ func TestAccConfigMeta(t *testing.T) { t.Fatalf("failed to validate returned client without error: %s", err.Error()) } }) - - t.Run("returns a v3 REST API client to manage organization resources", func(t *testing.T) { - skipUnlessHasOrgs(t) - - config := Config{ - Token: testAccConf.token, - BaseURL: baseURL, - Owner: testAccConf.owner, - } - meta, err := config.Meta() - if err != nil { - t.Fatalf("failed to return meta without error: %s", err.Error()) - } - - ctx := context.Background() - client := meta.(*Owner).v3client - _, _, err = client.Organizations.Get(ctx, testAccConf.owner) - if err != nil { - t.Fatalf("failed to validate returned client without error: %s", err.Error()) - } - }) - - t.Run("returns a v4 GraphQL API client to manage organization resources", func(t *testing.T) { - skipUnlessHasOrgs(t) - - config := Config{ - Token: testAccConf.token, - BaseURL: baseURL, - Owner: testAccConf.owner, - } - meta, err := config.Meta() - if err != nil { - t.Fatalf("failed to return meta without error: %s", err.Error()) - } - - client := meta.(*Owner).v4client - - var query struct { - Organization struct { - ViewerCanAdminister githubv4.Boolean - } `graphql:"organization(login: $login)"` - } - variables := map[string]any{ - "login": githubv4.String(testAccConf.owner), - } - err = client.Query(context.Background(), &query, variables) - if err != nil { - t.Fatalf("failed to validate returned client without error: %s", err.Error()) - } - - if query.Organization.ViewerCanAdminister != true { - t.Fatalf("unexpected response when validating client") - } - }) } func TestPreviewHeaderInjectorTransport_RoundTrip(t *testing.T) { diff --git a/github/data_source_github_repository_test.go b/github/data_source_github_repository_test.go index 515c639aab..b672299bf1 100644 --- a/github/data_source_github_repository_test.go +++ b/github/data_source_github_repository_test.go @@ -43,18 +43,17 @@ func TestAccDataSourceGithubRepository(t *testing.T) { } `, testAccConf.username, testAccConf.testUserRepository) - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "data.github_repository.test", "full_name", - fmt.Sprintf("%s/%s", testAccConf.username, testAccConf.testUserRepository)), - ) resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnauthenticated(t) }, + PreCheck: func() { skipUnlessMode(t, individual) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: check, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "data.github_repository.test", "full_name", + fmt.Sprintf("%s/%s", testAccConf.username, testAccConf.testUserRepository)), + ), }, }, }) diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..bf1de7dc5e 100644 --- a/github/provider.go +++ b/github/provider.go @@ -72,25 +72,25 @@ func Provider() *schema.Provider { "write_delay_ms": { Type: schema.TypeInt, Optional: true, - Default: 1000, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_WRITE_DELAY_MS", 1000), Description: descriptions["write_delay_ms"], }, "read_delay_ms": { Type: schema.TypeInt, Optional: true, - Default: 0, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_READ_DELAY_MS", 0), Description: descriptions["read_delay_ms"], }, "retry_delay_ms": { Type: schema.TypeInt, Optional: true, - Default: 1000, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_RETRY_DELAY_MS", 1000), Description: descriptions["retry_delay_ms"], }, "parallel_requests": { Type: schema.TypeBool, Optional: true, - Default: false, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_PARALLEL_REQUESTS", false), Description: descriptions["parallel_requests"], }, "app_auth": { @@ -378,35 +378,8 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { owner = org } - if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil { - appAuthAttr := appAuth[0].(map[string]any) - - var appID, appInstallationID, appPemFile string - - if v, ok := appAuthAttr["id"].(string); ok && v != "" { - appID = v - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.id must be set and contain a non-empty value")}) - } - - if v, ok := appAuthAttr["installation_id"].(string); ok && v != "" { - appInstallationID = v - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.installation_id must be set and contain a non-empty value")}) - } - - if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" { - // The Go encoding/pem package only decodes PEM formatted blocks - // that contain new lines. Some platforms, like Terraform Cloud, - // do not support new lines within Environment Variables. - // Any occurrence of \n in the `pem_file` argument's value - // (explicit value, or default value taken from - // GITHUB_APP_PEM_FILE Environment Variable) is replaced with an - // actual new line character before decoding. - appPemFile = strings.ReplaceAll(v, `\n`, "\n") - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.pem_file must be set and contain a non-empty value")}) - } + if appID, appInstallationID, appPemFile, ok := getAppAuth(d); ok { + log.Println("[INFO] Using app authentication") apiPath := "" if isGHES { @@ -415,7 +388,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { appToken, err := GenerateOAuthTokenFromApp(baseURL.JoinPath(apiPath), appID, appInstallationID, appPemFile) if err != nil { - return nil, wrapErrors([]error{err}) + return nil, diag.FromErr(err) } token = appToken @@ -427,9 +400,9 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } writeDelay := d.Get("write_delay_ms").(int) - if writeDelay <= 0 { - return nil, wrapErrors([]error{fmt.Errorf("write_delay_ms must be greater than 0ms")}) - } + // if writeDelay <= 0 { + // return nil, wrapErrors([]error{fmt.Errorf("write_delay_ms must be greater than 0ms")}) + // } log.Printf("[INFO] Setting write_delay_ms to %d", writeDelay) readDelay := d.Get("read_delay_ms").(int) @@ -497,6 +470,55 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } } +func getAppAuth(d *schema.ResourceData) (string, string, string, bool) { + appID := os.Getenv("GITHUB_APP_ID") + appInstallationID := os.Getenv("GITHUB_APP_INSTALLATION_ID") + appPEM := os.Getenv("GITHUB_APP_PEM_FILE") + + v, ok := d.GetOk("app_auth") + if !ok { + return validateAppAuth(appID, appInstallationID, appPEM) + } + + c, ok := v.([]any) + if !ok || len(c) == 0 || c[0] == nil { + return validateAppAuth(appID, appInstallationID, appPEM) + } + + appAuthAttr, ok := c[0].(map[string]any) + if !ok { + return validateAppAuth(appID, appInstallationID, appPEM) + } + + if o, ok := appAuthAttr["id"]; ok { + if s, ok := o.(string); ok && s != "" { + appID = s + } + } + + if o, ok := appAuthAttr["installation_id"]; ok { + if s, ok := o.(string); ok && s != "" { + appInstallationID = s + } + } + + if o, ok := appAuthAttr["pem_file"]; ok { + if s, ok := o.(string); ok && s != "" { + appPEM = s + } + } + + return validateAppAuth(appID, appInstallationID, appPEM) +} + +func validateAppAuth(appID, appInstallationID, appPEM string) (string, string, string, bool) { + if appID == "" || appInstallationID == "" || appPEM == "" { + return "", "", "", false + } + + return appID, appInstallationID, strings.ReplaceAll(appPEM, `\n`, "\n"), true +} + // ghCLIHostFromAPIHost maps an API hostname to the corresponding // gh-CLI --hostname value. For example api.github.com -> github.com // and api..ghe.com -> .ghe.com. diff --git a/github/provider_test.go b/github/provider_test.go index bb9ab01400..1890873af9 100644 --- a/github/provider_test.go +++ b/github/provider_test.go @@ -10,32 +10,27 @@ import ( ) func TestProvider(t *testing.T) { - t.Run("runs internal validation without error", func(t *testing.T) { + t.Run("validate", func(t *testing.T) { if err := Provider().InternalValidate(); err != nil { t.Fatalf("err: %s", err) } }) - - t.Run("has an implementation", func(t *testing.T) { - // FIXME: unsure if this is useful; refactored from: - // func TestProvider_impl(t *testing.T) { - // var _ terraform.ResourceProvider = Provider() - // } - - _ = *Provider() - }) } func TestAccProviderConfigure(t *testing.T) { - t.Run("can_be_configured_to_run_anonymously", func(t *testing.T) { + t.Run("anonymous", func(t *testing.T) { config := ` - provider "github" { - } - data "github_ip_ranges" "test" {} - ` +provider "github" {} + +data "github_ip_ranges" "test" {} +` resource.Test(t, resource.TestCase{ - PreCheck: func() { t.Setenv("GITHUB_TOKEN", ""); t.Setenv("GH_PATH", "none-existent-path") }, + PreCheck: func() { + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GITHUB_APP_PEM_FILE", "") + t.Setenv("GH_PATH", "none-existent-path") + }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { @@ -47,191 +42,195 @@ func TestAccProviderConfigure(t *testing.T) { }) }) - t.Run("can_be_configured_with_app_auth_and_ignore_github_token", func(t *testing.T) { - t.Skip("This test requires a valid app auth setup to run.") - config := fmt.Sprintf(` + t.Run("insecure", func(t *testing.T) { + config := ` provider "github" { - owner = "%s" - app_auth { - id = "1234567890" - installation_id = "1234567890" - pem_file = "1234567890" - } + insecure = true } - data "github_ip_ranges" "test" {} -`, testAccConf.owner) +` resource.Test(t, resource.TestCase{ - PreCheck: func() { t.Setenv("GITHUB_TOKEN", "1234567890") }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, + PlanOnly: true, ExpectNonEmptyPlan: false, }, }, }) }) - t.Run("can be configured to run insecurely", func(t *testing.T) { - config := ` - provider "github" { - insecure = true - } - data "github_ip_ranges" "test" {} - ` + t.Run("max_retries", func(t *testing.T) { + testMaxRetries := -1 + config := fmt.Sprintf(` +provider "github" { + max_retries = %d +} + +data "github_ip_ranges" "test" {} +`, testMaxRetries) resource.Test(t, resource.TestCase{ ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - PlanOnly: true, ExpectNonEmptyPlan: false, + ExpectError: regexp.MustCompile("max_retries must be greater than or equal to 0"), }, }, }) }) - t.Run("can be configured with an individual account", func(t *testing.T) { + t.Run("max_per_page", func(t *testing.T) { + testMaxPerPage := 101 config := fmt.Sprintf(` - provider "github" { - token = "%s" - owner = "%s" - } - `, testAccConf.token, testAccConf.owner) +provider "github" { + max_per_page = %d +} + +data "github_ip_ranges" "test" {} +`, testMaxPerPage) resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessMode(t, individual) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - PlanOnly: true, ExpectNonEmptyPlan: false, + Check: func(_ *terraform.State) error { + if maxPerPage != testMaxPerPage { + return fmt.Errorf("max_per_page should be set to %d, got %d", testMaxPerPage, maxPerPage) + } + return nil + }, }, }, }) }) - t.Run("can be configured with an organization account", func(t *testing.T) { + t.Run("app_auth", func(t *testing.T) { config := fmt.Sprintf(` - provider "github" { - token = "%s" - owner = "%[2]s" - } +provider "github" { + owner = "%s" + app_auth { + id = "%s" + installation_id = "%s" + pem_file = "%s" + } +} - data "github_organization" "test" { - name = "%[2]s" - } - `, testAccConf.token, testAccConf.owner) +data "github_ip_ranges" "test" {} +`, testAccConf.owner, testAccConf.appID, testAccConf.appInstallationID, testAccConf.appPEM) resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessHasOrgs(t) }, + PreCheck: func() { skipNoApp(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, + Config: config, + ExpectNonEmptyPlan: false, }, }, }) }) - t.Run("can be configured with an organization account legacy", func(t *testing.T) { + t.Run("app_auth_ignore_token_env", func(t *testing.T) { config := fmt.Sprintf(` - provider "github" { - token = "%s" - organization = "%s" - }`, testAccConf.token, testAccConf.owner) +provider "github" { + owner = "%s" + app_auth { + id = "%s" + installation_id = "%s" + pem_file = "%s" + } +} + +data "github_ip_ranges" "test" {} +`, testAccConf.owner, testAccConf.appID, testAccConf.appInstallationID, testAccConf.appPEM) resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessHasOrgs(t) }, + PreCheck: func() { skipNoApp(t); t.Setenv("GITHUB_TOKEN", "1234567890") }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - PlanOnly: true, ExpectNonEmptyPlan: false, }, }, }) }) - t.Run("can be configured with a GHES deployment", func(t *testing.T) { + t.Run("token_auth", func(t *testing.T) { config := fmt.Sprintf(` - provider "github" { - token = "%s" - base_url = "%s" - }`, testAccConf.token, testAccConf.baseURL) +provider "github" { + owner = "%s" + token = "%s" +} +`, testAccConf.token, testAccConf.owner) resource.Test(t, resource.TestCase{ - PreCheck: func() { - skipUnlessMode(t, enterprise) - if testAccConf.baseURL.Host != "api.github.com" { - t.Skip("Skipping as test mode is not GHES") - } - }, + PreCheck: func() { skipNoToken(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, + PlanOnly: true, ExpectNonEmptyPlan: false, }, }, }) }) - t.Run("can be configured with max retries", func(t *testing.T) { - testMaxRetries := -1 + t.Run("organization_account_with_token_legacy", func(t *testing.T) { config := fmt.Sprintf(` - provider "github" { - owner = "%s" - max_retries = %d - } - - data "github_ip_ranges" "test" {} - `, testAccConf.owner, testMaxRetries) +provider "github" { + token = "%s" + organization = "%s" +}`, testAccConf.token, testAccConf.owner) resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipNoToken(t) + skipUnlessHasOrgs(t) + }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, + PlanOnly: true, ExpectNonEmptyPlan: false, - ExpectError: regexp.MustCompile("max_retries must be greater than or equal to 0"), }, }, }) }) - t.Run("can be configured with max per page", func(t *testing.T) { - testMaxPerPage := 101 + t.Run("ghes_with_token", func(t *testing.T) { config := fmt.Sprintf(` - provider "github" { - owner = "%s" - max_per_page = %d - } - - data "github_ip_ranges" "test" {} - `, testAccConf.owner, testMaxPerPage) +provider "github" { + token = "%s" + base_url = "%s" +}`, testAccConf.token, testAccConf.baseURL) resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessMode(t, enterprise) + if testAccConf.baseURL.Host != "api.github.com" { + t.Skip("Skipping as test mode is not GHES") + } + }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, ExpectNonEmptyPlan: false, - Check: func(_ *terraform.State) error { - if maxPerPage != testMaxPerPage { - return fmt.Errorf("max_per_page should be set to %d, got %d", testMaxPerPage, maxPerPage) - } - return nil - }, }, }, }) }) + t.Run("should not allow both token and app_auth to be configured", func(t *testing.T) { t.Skip("This would be a semver breaking change, this will be reinstated for v7.") config := fmt.Sprintf(` @@ -249,6 +248,10 @@ data "github_ip_ranges" "test" {} `, testAccConf.owner, testAccConf.token) resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipNoToken(t) + skipNoApp(t) + }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { diff --git a/github/resource_github_branch_protection_test.go b/github/resource_github_branch_protection_test.go index 6fa000258d..9df647a483 100644 --- a/github/resource_github_branch_protection_test.go +++ b/github/resource_github_branch_protection_test.go @@ -13,6 +13,10 @@ import ( ) func TestAccGithubBranchProtectionV4(t *testing.T) { + if len(testAccConf.username) == 0 { + t.Skip("Tests require the username to be set.") + } + t.Run("configures default settings when empty", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) diff --git a/github/resource_github_issue_test.go b/github/resource_github_issue_test.go index 57d830d839..11d34df675 100644 --- a/github/resource_github_issue_test.go +++ b/github/resource_github_issue_test.go @@ -48,62 +48,57 @@ func TestAccGithubIssue(t *testing.T) { ` config := fmt.Sprintf(issueHCL, repoName, title, body, labels, testAccConf.username) - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_issue.test", "title", - title, - ), - resource.TestCheckResourceAttr( - "github_issue.test", "body", - body, - ), - resource.TestCheckResourceAttr( - "github_issue.test", "labels.#", - "2", - ), - func(state *terraform.State) error { - issue := state.RootModule().Resources["github_issue.test"].Primary - issueMilestone := issue.Attributes["milestone_number"] - - milestone := state.RootModule().Resources["github_repository_milestone.test"].Primary - milestoneNumber := milestone.Attributes["number"] - if issueMilestone != milestoneNumber { - return fmt.Errorf("issue milestone number %s not the same as repository milestone number %s", - issueMilestone, milestoneNumber) - } - return nil - }, - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_issue.test", "title", - updatedTitle, - ), - resource.TestCheckResourceAttr( - "github_issue.test", "body", - updatedBody, - ), resource.TestCheckResourceAttr( - "github_issue.test", "labels.#", - "1", - ), resource.TestCheckResourceAttr( - "github_issue.test", "assignees.#", - "1", - ), - ), - } - resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnauthenticated(t) }, + PreCheck: func() { skipUnlessMode(t, individual) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: checks["before"], + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_issue.test", "title", + title, + ), + resource.TestCheckResourceAttr( + "github_issue.test", "body", + body, + ), + resource.TestCheckResourceAttr( + "github_issue.test", "labels.#", + "2", + ), + func(state *terraform.State) error { + issue := state.RootModule().Resources["github_issue.test"].Primary + issueMilestone := issue.Attributes["milestone_number"] + + milestone := state.RootModule().Resources["github_repository_milestone.test"].Primary + milestoneNumber := milestone.Attributes["number"] + if issueMilestone != milestoneNumber { + return fmt.Errorf("issue milestone number %s not the same as repository milestone number %s", + issueMilestone, milestoneNumber) + } + return nil + }, + ), }, { Config: fmt.Sprintf(issueHCL, repoName, updatedTitle, updatedBody, updatedLabels, testAccConf.owner), - Check: checks["after"], + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_issue.test", "title", + updatedTitle, + ), + resource.TestCheckResourceAttr( + "github_issue.test", "body", + updatedBody, + ), resource.TestCheckResourceAttr( + "github_issue.test", "labels.#", + "1", + ), resource.TestCheckResourceAttr( + "github_issue.test", "assignees.#", + "1", + ), + ), }, }, }) diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index 28408af769..b823c0d145 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -75,7 +75,7 @@ provider "github" { ### GitHub App Installation To authenticate using a GitHub App installation, ensure that arguments in the `app_auth` block or the `GITHUB_APP_XXX` environment variables are set. -The `owner` parameter required in this situation. Leaving out will throw a `403 "Resource not accessible by integration"` error. +The `owner` parameter is required in this situation. Leaving out will throw a `403 "Resource not accessible by integration"` error. Some API operations may not be available when using a GitHub App installation configuration. For more information, refer to the list of [supported endpoints](https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps). @@ -90,8 +90,6 @@ provider "github" { } ``` -~> **Note:** When using environment variables, an empty `app_auth` block is required to allow provider configurations from environment variables to be specified. See: https://github.com/hashicorp/terraform-plugin-sdk/issues/142 - ```terraform provider "github" { owner = var.github_organization