Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions github/resource_github_enterprise_organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"log"
"strings"

"github.com/google/go-github/v67/github"
Expand Down Expand Up @@ -163,14 +162,37 @@ func resourceGithubEnterpriseOrganizationRead(data *schema.ResourceData, meta an

var adminLogins []any

owner := meta.(*Owner)

for {
v4 := meta.(*Owner).v4client
v4 := owner.v4client
err := v4.Query(context.Background(), &query, variables)
if err != nil {
if strings.Contains(err.Error(), "Could not resolve to a node with the global id") {
log.Printf("[INFO] Removing organization (%s) from state because it no longer exists in GitHub", data.Id())
data.SetId("")
return nil
// The GraphQL error "Could not resolve to a node" can mean either:
// 1. The org was actually deleted
// 2. The org exists but the PAT hasn't been authorized for it yet (EMU/SSO)
//
// In EMU/SSO environments, both GraphQL and REST may return not-found errors
// for orgs that exist but the PAT isn't authorized to access. We cannot
// reliably distinguish "deleted" from "unauthorized" based on API responses.
//
// To avoid incorrectly removing the org from state (which causes Terraform
// to destroy it), we always return an error and let the user investigate.
// If the org was truly deleted, use: terraform state rm <resource_address>
orgName := data.Get("name").(string)
v3 := owner.v3client
ctx := context.WithValue(context.Background(), ctxId, data.Id())
_, _, restErr := v3.Organizations.Get(ctx, orgName)

if restErr == nil {
// REST succeeded - org definitely exists, GraphQL access issue
return fmt.Errorf("organization %q exists but cannot be read via GraphQL. This typically occurs when the PAT has not been authorized for the organization yet. Please authorize the PAT via GitHub UI and retry. Original error: %w", orgName, err)
}

// REST also failed - could be deleted OR unauthorized
// Do NOT remove from state to avoid accidental destruction
return fmt.Errorf("cannot read organization %q via GraphQL or REST API. If the organization was deleted, remove it from state with: terraform state rm <resource_address>. GraphQL error: %v, REST error: %w", orgName, err, restErr)
}
return err
}
Expand Down
156 changes: 156 additions & 0 deletions github/resource_github_enterprise_organization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ package github

import (
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"

"github.com/google/go-github/v67/github"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/shurcooL/githubv4"
)

func TestAccGithubEnterpriseOrganization(t *testing.T) {
Expand Down Expand Up @@ -569,3 +574,154 @@ func TestAccGithubEnterpriseOrganization(t *testing.T) {
})
})
}

// TestEnterpriseOrganizationReadGraphQLErrorHandling tests the Read function's
// behavior when GraphQL returns "Could not resolve to a node" error.
// This can happen when:
// 1. The org was actually deleted
// 2. The org exists but the PAT hasn't been authorized for it yet (EMU/SSO)
func TestEnterpriseOrganizationReadGraphQLErrorHandling(t *testing.T) {
graphqlNotFoundResponse := `{
"data": { "node": null },
"errors": [{
"type": "NOT_FOUND",
"path": ["node"],
"message": "Could not resolve to a node with the global id of 'O_test123'"
}]
}`

t.Run("returns error and preserves state when REST returns 404 (could be deleted or unauthorized)", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(graphqlNotFoundResponse))
})
mux.HandleFunc("/api/v3/orgs/test-org", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message": "Not Found"}`))
})

server := httptest.NewServer(mux)
defer server.Close()

v3client, _ := github.NewClient(nil).WithEnterpriseURLs(server.URL+"/api/v3/", server.URL+"/")
meta := &Owner{
v4client: githubv4.NewClient(&http.Client{Transport: localRoundTripper{handler: mux}}),
v3client: v3client,
}

resourceData := schema.TestResourceDataRaw(t, resourceGithubEnterpriseOrganization().Schema, map[string]interface{}{
"name": "test-org",
"enterprise_id": "E_test",
"billing_email": "[email protected]",
"admin_logins": []interface{}{"admin"},
})
resourceData.SetId("O_test123")

err := resourceGithubEnterpriseOrganizationRead(resourceData, meta)

if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "cannot read organization") {
t.Fatalf("expected 'cannot read organization' error, got: %v", err)
}
if !strings.Contains(err.Error(), "terraform state rm") {
t.Fatalf("expected guidance to use 'terraform state rm', got: %v", err)
}
if resourceData.Id() == "" {
t.Fatal("expected resource ID to NOT be cleared (to prevent accidental destruction)")
}
})

t.Run("returns error when org exists but GraphQL can't access it (REST 200)", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(graphqlNotFoundResponse))
})
mux.HandleFunc("/api/v3/orgs/test-org", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"login": "test-org", "id": 123}`))
})

server := httptest.NewServer(mux)
defer server.Close()

v3client, _ := github.NewClient(nil).WithEnterpriseURLs(server.URL+"/api/v3/", server.URL+"/")
meta := &Owner{
v4client: githubv4.NewClient(&http.Client{Transport: localRoundTripper{handler: mux}}),
v3client: v3client,
}

resourceData := schema.TestResourceDataRaw(t, resourceGithubEnterpriseOrganization().Schema, map[string]interface{}{
"name": "test-org",
"enterprise_id": "E_test",
"billing_email": "[email protected]",
"admin_logins": []interface{}{"admin"},
})
resourceData.SetId("O_test123")

err := resourceGithubEnterpriseOrganizationRead(resourceData, meta)

if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "exists but cannot be read via GraphQL") {
t.Fatalf("expected PAT authorization error message, got: %v", err)
}
if !strings.Contains(err.Error(), "authorize the PAT") {
t.Fatalf("expected guidance to authorize PAT, got: %v", err)
}
if resourceData.Id() == "" {
t.Fatal("expected resource ID to NOT be cleared")
}
})

t.Run("returns error and preserves state when REST fails with 403", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(graphqlNotFoundResponse))
})
mux.HandleFunc("/api/v3/orgs/test-org", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"message": "Forbidden"}`))
})

server := httptest.NewServer(mux)
defer server.Close()

v3client, _ := github.NewClient(nil).WithEnterpriseURLs(server.URL+"/api/v3/", server.URL+"/")
meta := &Owner{
v4client: githubv4.NewClient(&http.Client{Transport: localRoundTripper{handler: mux}}),
v3client: v3client,
}

resourceData := schema.TestResourceDataRaw(t, resourceGithubEnterpriseOrganization().Schema, map[string]interface{}{
"name": "test-org",
"enterprise_id": "E_test",
"billing_email": "[email protected]",
"admin_logins": []interface{}{"admin"},
})
resourceData.SetId("O_test123")

err := resourceGithubEnterpriseOrganizationRead(resourceData, meta)

if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "cannot read organization") {
t.Fatalf("expected 'cannot read organization' error, got: %v", err)
}
if !strings.Contains(err.Error(), "GraphQL error") {
t.Fatalf("expected GraphQL error in message, got: %v", err)
}
if !strings.Contains(err.Error(), "REST error") {
t.Fatalf("expected REST error in message, got: %v", err)
}
if resourceData.Id() == "" {
t.Fatal("expected resource ID to NOT be cleared")
}
})
}