diff --git a/shared/client/client_test.go b/shared/client/client_test.go index 6ebfac1f..f9626784 100644 --- a/shared/client/client_test.go +++ b/shared/client/client_test.go @@ -604,3 +604,21 @@ func TestOptions_timeoutOrDefault(t *testing.T) { } }) } + +func TestGatewayBaseURLFromEnv(t *testing.T) { + t.Setenv("JIRA_GATEWAY_BASE_URL", "") + t.Setenv("ATLASSIAN_GATEWAY_BASE_URL", "") + if got := GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"); got != GatewayBaseURL { + t.Fatalf("default gateway = %q, want %q", got, GatewayBaseURL) + } + + t.Setenv("ATLASSIAN_GATEWAY_BASE_URL", "https://shared.example/") + if got := GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"); got != "https://shared.example" { + t.Fatalf("shared gateway = %q", got) + } + + t.Setenv("JIRA_GATEWAY_BASE_URL", "https://jira.example/") + if got := GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"); got != "https://jira.example" { + t.Fatalf("tool gateway = %q", got) + } +} diff --git a/shared/client/options.go b/shared/client/options.go index e28ec8c4..c988396d 100644 --- a/shared/client/options.go +++ b/shared/client/options.go @@ -2,6 +2,8 @@ package client import ( "io" + "os" + "strings" "time" ) @@ -12,6 +14,21 @@ const DefaultTimeout = 60 * time.Second // with scoped API tokens (service accounts). const GatewayBaseURL = "https://api.atlassian.com" +// GatewayBaseURLFromEnv returns a gateway base URL using tool-specific +// precedence, then the shared ATLASSIAN_GATEWAY_BASE_URL override, then +// the Atlassian Cloud gateway default. +func GatewayBaseURLFromEnv(primaryEnv string) string { + for _, name := range []string{primaryEnv, "ATLASSIAN_GATEWAY_BASE_URL"} { + if name == "" { + continue + } + if v := strings.TrimRight(strings.TrimSpace(os.Getenv(name)), "/"); v != "" { + return v + } + } + return GatewayBaseURL +} + // Options configures client behavior. type Options struct { // Timeout for HTTP requests. Defaults to 60 seconds if not set. @@ -27,6 +44,10 @@ type Options struct { // Use auth.BearerAuthHeader() for service accounts with scoped tokens. // When empty, New() computes BasicAuthHeader(email, apiToken) as before. AuthHeader string + + // SkipAuthHeader suppresses the Authorization header entirely. + // Use this for proxy auth, where a trusted proxy injects credentials. + SkipAuthHeader bool } // timeoutOrDefault returns the configured timeout or the default. diff --git a/shared/url/url.go b/shared/url/url.go index 48545c86..b671927c 100644 --- a/shared/url/url.go +++ b/shared/url/url.go @@ -1,7 +1,32 @@ // Package url provides URL normalization utilities for Atlassian CLI tools. package url -import "strings" +import ( + "errors" + "net/netip" + neturl "net/url" + "strings" +) + +// ErrRequiresHTTPS is returned for a cleartext non-loopback URL. +var ErrRequiresHTTPS = errors.New("url must use https unless it is loopback http") + +// RequireSecureOrLoopback permits HTTPS endpoints and local HTTP endpoints. +// Scheme-less URLs are normalized to HTTPS, as they are by API clients. +func RequireSecureOrLoopback(u string) error { + if strings.Contains(u, "://") && !HasScheme(u) { + return ErrRequiresHTTPS + } + u = NormalizeURL(u) + parsed, err := neturl.Parse(u) + if err != nil || parsed.Host == "" { + return ErrRequiresHTTPS + } + if parsed.Scheme == "https" || IsLoopbackHTTP(u) { + return nil + } + return ErrRequiresHTTPS +} // NormalizeURL ensures the URL has an https scheme and no trailing slashes. // If the URL is empty, it returns an empty string. @@ -36,3 +61,19 @@ func HasScheme(u string) bool { func TrimTrailingSlashes(u string) string { return strings.TrimRight(u, "/") } + +// IsLoopbackHTTP reports whether u is an http:// URL whose host is localhost +// or a loopback IP address. It is intentionally narrow so CLIs can allow local +// development/proxy endpoints without allowing arbitrary cleartext URLs. +func IsLoopbackHTTP(u string) bool { + parsed, err := neturl.Parse(u) + if err != nil || parsed.Scheme != "http" || parsed.Host == "" { + return false + } + host := strings.ToLower(parsed.Hostname()) + if host == "localhost" { + return true + } + addr, err := netip.ParseAddr(host) + return err == nil && addr.IsLoopback() +} diff --git a/shared/url/url_test.go b/shared/url/url_test.go index 76ad91d5..41615317 100644 --- a/shared/url/url_test.go +++ b/shared/url/url_test.go @@ -1,6 +1,38 @@ package url //nolint:revive // test file for url package -import "testing" +import ( + "errors" + "testing" +) + +func TestRequireSecureOrLoopback(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + url string + valid bool + }{ + {"https://proxy.example.com/path", true}, + {"proxy.example.com/path", true}, + {"http://localhost:8080/path", true}, + {"http://127.0.0.1:8080/path", true}, + {"http://[::1]:8080/path", true}, + {"http://proxy.example.com/path", false}, + {"ftp://proxy.example.com/path", false}, + {"https://", false}, + {"", false}, + } { + t.Run(tt.url, func(t *testing.T) { + t.Parallel() + err := RequireSecureOrLoopback(tt.url) + if tt.valid && err != nil { + t.Fatalf("RequireSecureOrLoopback(%q) = %v, want nil", tt.url, err) + } + if !tt.valid && !errors.Is(err, ErrRequiresHTTPS) { + t.Fatalf("RequireSecureOrLoopback(%q) = %v, want ErrRequiresHTTPS", tt.url, err) + } + }) + } +} func TestNormalizeURL(t *testing.T) { t.Parallel() @@ -115,3 +147,31 @@ func TestTrimTrailingSlashes(t *testing.T) { }) } } + +func TestIsLoopbackHTTP(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want bool + }{ + {"http://localhost:8080/atlassian", true}, + {"http://LOCALHOST/atlassian", true}, + {"http://127.0.0.1:8080/atlassian", true}, + {"http://127.42.0.1:8080/atlassian", true}, + {"http://[::1]:8080/atlassian", true}, + {"https://localhost:8080/atlassian", false}, + {"http://example.com/atlassian", false}, + {"http://10.0.0.1/atlassian", false}, + {"localhost:8080/atlassian", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + if got := IsLoopbackHTTP(tt.input); got != tt.want { + t.Errorf("IsLoopbackHTTP(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} diff --git a/tools/cfl/README.md b/tools/cfl/README.md index ee3854f1..ba85d6a2 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -925,6 +925,9 @@ Environment variables override file-based config. Variables are checked in order | Default Space | `CFL_DEFAULT_SPACE` → shared `cfl.default_space` → legacy | | Auth Method | `CFL_AUTH_METHOD` → `ATLASSIAN_AUTH_METHOD` → shared `default` → legacy → `basic` | | Cloud ID | `CFL_CLOUD_ID` → `ATLASSIAN_CLOUD_ID` → shared `default` → legacy | +| Bearer Gateway Base URL | `CFL_GATEWAY_BASE_URL` → `ATLASSIAN_GATEWAY_BASE_URL` → `https://api.atlassian.com` | + +Gateway overrides must use HTTPS, except for loopback HTTP endpoints used in local testing. Per §2.2 connection config is single-sourced from the shared `default` section — per-tool `cfl:`/`jtk:` sections carry only non-secret defaults and may not override `url`/`email`/`auth_method`/`cloud_id`. diff --git a/tools/cfl/api/client.go b/tools/cfl/api/client.go index bc523c0d..6c0afd51 100644 --- a/tools/cfl/api/client.go +++ b/tools/cfl/api/client.go @@ -11,12 +11,15 @@ import ( "github.com/open-cli-collective/atlassian-go/auth" "github.com/open-cli-collective/atlassian-go/client" + sharedurl "github.com/open-cli-collective/atlassian-go/url" ) -// Validation errors for bearer auth. +// Validation errors for auth configuration. var ( ErrAPITokenRequired = errors.New("API token is required") ErrCloudIDRequired = errors.New("cloud ID is required for bearer auth") + ErrURLRequired = errors.New("URL is required") + ErrEmailRequired = errors.New("email is required") ) // Client is the Confluence Cloud API client. @@ -25,6 +28,40 @@ type Client struct { *client.Client } +// ClientConfig contains the inputs for a Confluence API client. +type ClientConfig struct { + URL string + Email string + APIToken string + AuthMethod string + CloudID string + GatewayBaseURL string // Optional bearer gateway base; defaults to api.atlassian.com +} + +// New dispatches all supported authentication methods through one entry point. +func New(cfg ClientConfig) (*Client, error) { + if cfg.AuthMethod != "" { + if err := auth.ValidateAuthMethod(cfg.AuthMethod); err != nil { + return nil, err + } + } + switch cfg.AuthMethod { + case auth.AuthMethodBearer: + return newBearerClient(cfg) + default: + if cfg.URL == "" { + return nil, ErrURLRequired + } + if cfg.Email == "" { + return nil, ErrEmailRequired + } + if cfg.APIToken == "" { + return nil, ErrAPITokenRequired + } + return NewClient(cfg.URL, cfg.Email, cfg.APIToken), nil + } +} + // NewClient creates a new Confluence API client using basic auth. func NewClient(baseURL, email, apiToken string) *Client { return &Client{ @@ -35,15 +72,26 @@ func NewClient(baseURL, email, apiToken string) *Client { // NewBearerClient creates a new Confluence API client using bearer auth via the API gateway. // The cloudID is used to construct the gateway URL: https://api.atlassian.com/ex/confluence/{cloudId}/wiki func NewBearerClient(apiToken, cloudID string) (*Client, error) { - if apiToken == "" { + return New(ClientConfig{APIToken: apiToken, CloudID: cloudID, AuthMethod: auth.AuthMethodBearer}) +} + +func newBearerClient(cfg ClientConfig) (*Client, error) { + if cfg.APIToken == "" { return nil, ErrAPITokenRequired } - if cloudID == "" { + if cfg.CloudID == "" { return nil, ErrCloudIDRequired } - gatewayBase := fmt.Sprintf("%s/ex/confluence/%s/wiki", client.GatewayBaseURL, cloudID) + gatewayURL := cfg.GatewayBaseURL + if gatewayURL == "" { + gatewayURL = client.GatewayBaseURL + } + if err := sharedurl.RequireSecureOrLoopback(gatewayURL); err != nil { + return nil, fmt.Errorf("invalid gateway base URL: %w", err) + } + gatewayBase := fmt.Sprintf("%s/ex/confluence/%s/wiki", sharedurl.NormalizeURL(gatewayURL), cfg.CloudID) opts := &client.Options{ - AuthHeader: auth.BearerAuthHeader(apiToken), + AuthHeader: auth.BearerAuthHeader(cfg.APIToken), } return &Client{ Client: client.New(gatewayBase, "", "", opts), diff --git a/tools/cfl/api/client_test.go b/tools/cfl/api/client_test.go index 9e9fd9bb..a7425875 100644 --- a/tools/cfl/api/client_test.go +++ b/tools/cfl/api/client_test.go @@ -12,6 +12,7 @@ import ( "github.com/open-cli-collective/atlassian-go/client" "github.com/open-cli-collective/atlassian-go/testutil" + sharedurl "github.com/open-cli-collective/atlassian-go/url" ) func TestNewClient(t *testing.T) { @@ -243,3 +244,23 @@ func TestNewBearerClient(t *testing.T) { testutil.Equal(t, "https://example.atlassian.net/wiki", c.GetBaseURL()) }) } + +func TestNew_BearerGatewayOverride(t *testing.T) { + c, err := New(ClientConfig{APIToken: "scoped-token", CloudID: "abc-123", AuthMethod: "bearer", GatewayBaseURL: "https://gateway.example/"}) + testutil.RequireNoError(t, err) + testutil.Equal(t, "https://gateway.example/ex/confluence/abc-123/wiki", c.GetBaseURL()) +} + +func TestNew_BearerRejectsCleartextGateway(t *testing.T) { + c, err := New(ClientConfig{APIToken: "token", CloudID: "cloud", AuthMethod: "bearer", GatewayBaseURL: "http://external.example"}) + if !errors.Is(err, sharedurl.ErrRequiresHTTPS) || c != nil { + t.Fatalf("New() = (%v, %v), want secure URL error", c, err) + } +} + +func TestNew_BearerDoesNotReadGatewayEnv(t *testing.T) { + t.Setenv("CFL_GATEWAY_BASE_URL", "https://ambient.example") + c, err := New(ClientConfig{APIToken: "token", CloudID: "cloud", AuthMethod: "bearer"}) + testutil.RequireNoError(t, err) + testutil.Equal(t, "https://api.atlassian.com/ex/confluence/cloud/wiki", c.GetBaseURL()) +} diff --git a/tools/cfl/docs/development.md b/tools/cfl/docs/development.md index 96b0dfc2..ee25fad0 100644 --- a/tools/cfl/docs/development.md +++ b/tools/cfl/docs/development.md @@ -86,6 +86,8 @@ Add new Confluence macros through `MacroRegistry` in `macro.go`; the tokenizer, Basic auth uses an instance URL plus email and token. Bearer auth routes through `api.atlassian.com` and requires a cloud ID. `cfl init` and `cfl me` verify against Confluence's current-user endpoint. +Bearer gateway routing can be overridden with `CFL_GATEWAY_BASE_URL` or `ATLASSIAN_GATEWAY_BASE_URL`; overrides must use HTTPS or loopback HTTP. Gateway values are resolved by commands and passed explicitly to API clients. + ## Output `cfl` is markdown-first for page content. The target text-output contract lives in `internal/cmd/OUTPUT_SPEC.md`. The presenter migration guide lives in `internal/present/README.md` and records the cfl-specific command/presenter/renderer boundaries for #271. Resource `-o json` is removed; JSON is reserved for control-plane envelopes documented by the shared standards. diff --git a/tools/cfl/internal/cmd/init/init.go b/tools/cfl/internal/cmd/init/init.go index c2b57b41..bd65093d 100644 --- a/tools/cfl/internal/cmd/init/init.go +++ b/tools/cfl/internal/cmd/init/init.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/open-cli-collective/atlassian-go/auth" + "github.com/open-cli-collective/atlassian-go/client" "github.com/open-cli-collective/atlassian-go/credstore" "github.com/open-cli-collective/atlassian-go/keyring" "github.com/open-cli-collective/atlassian-go/prompt" @@ -23,14 +24,15 @@ import ( // clientBuilder constructs an *api.Client from a config. // Pulled out as a parameter so tests can inject an httptest-pointed client -// without depending on api.NewBearerClient's hardcoded gateway URL. +// without depending on the bearer gateway URL. type clientBuilder func(cfg *config.Config) (*api.Client, error) func defaultClientBuilder(cfg *config.Config) (*api.Client, error) { - if cfg.AuthMethod == auth.AuthMethodBearer { - return api.NewBearerClient(cfg.APIToken, cfg.CloudID) - } - return api.NewClient(cfg.URL, cfg.Email, cfg.APIToken), nil + return api.New(api.ClientConfig{ + URL: cfg.URL, Email: cfg.Email, APIToken: cfg.APIToken, + AuthMethod: cfg.AuthMethod, CloudID: cfg.CloudID, + GatewayBaseURL: client.GatewayBaseURLFromEnv("CFL_GATEWAY_BASE_URL"), + }) } // Register adds the init command to the root command. diff --git a/tools/cfl/internal/cmd/root/root.go b/tools/cfl/internal/cmd/root/root.go index 2116da2e..94fbe015 100644 --- a/tools/cfl/internal/cmd/root/root.go +++ b/tools/cfl/internal/cmd/root/root.go @@ -11,7 +11,7 @@ import ( cccredstore "github.com/open-cli-collective/cli-common/credstore" "github.com/open-cli-collective/atlassian-go/artifact" - "github.com/open-cli-collective/atlassian-go/auth" + "github.com/open-cli-collective/atlassian-go/client" "github.com/open-cli-collective/atlassian-go/keyring" "github.com/open-cli-collective/atlassian-go/present" "github.com/open-cli-collective/atlassian-go/version" @@ -141,10 +141,11 @@ func (o *Options) APIClient() (*api.Client, error) { if err != nil { return nil, err } - if cfg.AuthMethod == auth.AuthMethodBearer { - return api.NewBearerClient(cfg.APIToken, cfg.CloudID) - } - return api.NewClient(cfg.URL, cfg.Email, cfg.APIToken), nil + return api.New(api.ClientConfig{ + URL: cfg.URL, Email: cfg.Email, APIToken: cfg.APIToken, + AuthMethod: cfg.AuthMethod, CloudID: cfg.CloudID, + GatewayBaseURL: client.GatewayBaseURLFromEnv("CFL_GATEWAY_BASE_URL"), + }) } // SetAPIClient sets a test client (for testing only) diff --git a/tools/cfl/internal/cmd/root/root_test.go b/tools/cfl/internal/cmd/root/root_test.go index dbc98226..d337191c 100644 --- a/tools/cfl/internal/cmd/root/root_test.go +++ b/tools/cfl/internal/cmd/root/root_test.go @@ -450,3 +450,12 @@ func TestDefaultConfigLayersSharedValues(t *testing.T) { t.Fatalf("Execute failed: %v", err) } } + +func TestOptions_APIClient_UsesGatewayOverride(t *testing.T) { + t.Setenv("CFL_GATEWAY_BASE_URL", "https://gateway.example/") + _, opts := NewCmd() + opts.SetConfig(&config.Config{URL: "https://example.atlassian.net/wiki", APIToken: "token", AuthMethod: auth.AuthMethodBearer, CloudID: "cloud-123"}) + c, err := opts.APIClient() + testutil.RequireNoError(t, err) + testutil.Equal(t, "https://gateway.example/ex/confluence/cloud-123/wiki", c.GetBaseURL()) +} diff --git a/tools/jtk/README.md b/tools/jtk/README.md index 4c55bc5c..5d29dc66 100644 --- a/tools/jtk/README.md +++ b/tools/jtk/README.md @@ -1744,6 +1744,9 @@ Environment variables override file-based config. Variables are checked in order | Default Project | `JIRA_DEFAULT_PROJECT` → shared `jtk.default_project` → legacy | | Auth Method | `JIRA_AUTH_METHOD` → `ATLASSIAN_AUTH_METHOD` → shared `default` → legacy → `basic` | | Cloud ID | `JIRA_CLOUD_ID` → `ATLASSIAN_CLOUD_ID` → shared `default` → legacy | +| Bearer Gateway Base URL | `JIRA_GATEWAY_BASE_URL` → `ATLASSIAN_GATEWAY_BASE_URL` → `https://api.atlassian.com` | + +Gateway overrides must use HTTPS, except for loopback HTTP endpoints used in local testing. Per §2.2 connection config is single-sourced from the shared `default` section — per-tool `cfl:`/`jtk:` sections carry only non-secret defaults and may not override `url`/`email`/`auth_method`/`cloud_id`. diff --git a/tools/jtk/api/client.go b/tools/jtk/api/client.go index 1808ba24..3e944660 100644 --- a/tools/jtk/api/client.go +++ b/tools/jtk/api/client.go @@ -35,12 +35,13 @@ type Client struct { // ClientConfig contains configuration for creating a new client type ClientConfig struct { - URL string // Full Jira URL (e.g., https://mycompany.atlassian.net or https://jira.internal.corp.com) - Email string - APIToken string - Verbose bool - AuthMethod string // "basic" (default) or "bearer" - CloudID string // Required for bearer auth (used to construct gateway URL) + URL string // Full Jira URL (e.g., https://mycompany.atlassian.net or https://jira.internal.corp.com) + Email string + APIToken string + Verbose bool + AuthMethod string // "basic" (default) or "bearer" + CloudID string // Required for bearer auth (used to construct gateway URL) + GatewayBaseURL string // Optional bearer gateway base; defaults to api.atlassian.com } // New creates a new Jira API client from config. @@ -89,6 +90,9 @@ func New(cfg ClientConfig) (*Client, error) { // newBearerClient creates a client configured for bearer auth via the API gateway. func newBearerClient(cfg ClientConfig) (*Client, error) { + if cfg.APIToken == "" { + return nil, ErrAPITokenRequired + } if cfg.CloudID == "" { return nil, ErrCloudIDRequired } @@ -97,7 +101,14 @@ func newBearerClient(cfg ClientConfig) (*Client, error) { instanceURL := url.NormalizeURL(cfg.URL) // Gateway URLs for bearer auth - gatewayBase := fmt.Sprintf("%s/ex/jira/%s", client.GatewayBaseURL, cfg.CloudID) + gatewayURL := cfg.GatewayBaseURL + if gatewayURL == "" { + gatewayURL = client.GatewayBaseURL + } + if err := url.RequireSecureOrLoopback(gatewayURL); err != nil { + return nil, fmt.Errorf("invalid gateway base URL: %w", err) + } + gatewayBase := fmt.Sprintf("%s/ex/jira/%s", url.NormalizeURL(gatewayURL), cfg.CloudID) restURL := gatewayBase + "/rest/api/3" opts := &client.Options{ diff --git a/tools/jtk/api/client_test.go b/tools/jtk/api/client_test.go index 478747fb..34acba55 100644 --- a/tools/jtk/api/client_test.go +++ b/tools/jtk/api/client_test.go @@ -13,6 +13,7 @@ import ( "github.com/open-cli-collective/atlassian-go/auth" "github.com/open-cli-collective/atlassian-go/client" "github.com/open-cli-collective/atlassian-go/testutil" + sharedurl "github.com/open-cli-collective/atlassian-go/url" ) func TestNew(t *testing.T) { @@ -473,3 +474,29 @@ func TestNew_BearerAuth(t *testing.T) { } }) } + +func TestNew_BearerAuth_GatewayBaseOverride(t *testing.T) { + c, err := New(ClientConfig{ + URL: "https://example.atlassian.net", + APIToken: "scoped-token", + AuthMethod: "bearer", + CloudID: "abc-123", + GatewayBaseURL: "https://gateway.example/", + }) + testutil.RequireNoError(t, err) + testutil.Equal(t, "https://gateway.example/ex/jira/abc-123/rest/api/3", c.BaseURL) +} + +func TestNew_BearerAuth_RejectsCleartextGateway(t *testing.T) { + c, err := New(ClientConfig{URL: "https://example.atlassian.net", APIToken: "token", AuthMethod: "bearer", CloudID: "cloud", GatewayBaseURL: "http://external.example"}) + if !errors.Is(err, sharedurl.ErrRequiresHTTPS) || c != nil { + t.Fatalf("New() = (%v, %v), want secure URL error", c, err) + } +} + +func TestNew_BearerAuth_DoesNotReadGatewayEnv(t *testing.T) { + t.Setenv("JIRA_GATEWAY_BASE_URL", "https://ambient.example") + c, err := New(ClientConfig{URL: "https://example.atlassian.net", APIToken: "token", AuthMethod: "bearer", CloudID: "cloud"}) + testutil.RequireNoError(t, err) + testutil.Equal(t, "https://api.atlassian.com/ex/jira/cloud/rest/api/3", c.BaseURL) +} diff --git a/tools/jtk/docs/development.md b/tools/jtk/docs/development.md index f9a3014f..6f3d1a33 100644 --- a/tools/jtk/docs/development.md +++ b/tools/jtk/docs/development.md @@ -77,6 +77,8 @@ Read `internal/cmd/OUTPUT_SPEC.md` before changing default output, `--id`, `--fi Basic auth uses an instance URL plus email and token. Bearer auth routes through `api.atlassian.com`, requires a cloud ID, and has Atlassian platform scope limitations for some Jira surfaces. +Bearer gateway routing can be overridden with `JIRA_GATEWAY_BASE_URL` or `ATLASSIAN_GATEWAY_BASE_URL`; overrides must use HTTPS or loopback HTTP. Gateway values are resolved by commands and passed explicitly to API clients. + ## Testing Notes Use `shared/testutil` for assertions. Prefer table-driven tests and `httptest.NewServer` for API client behavior. Keep tests next to implementation and use presenter-focused tests for output behavior. diff --git a/tools/jtk/internal/cmd/initcmd/initcmd.go b/tools/jtk/internal/cmd/initcmd/initcmd.go index 4cfa6689..8edc5ef7 100644 --- a/tools/jtk/internal/cmd/initcmd/initcmd.go +++ b/tools/jtk/internal/cmd/initcmd/initcmd.go @@ -17,6 +17,7 @@ import ( "github.com/spf13/cobra" "github.com/open-cli-collective/atlassian-go/auth" + "github.com/open-cli-collective/atlassian-go/client" "github.com/open-cli-collective/atlassian-go/credstore" "github.com/open-cli-collective/atlassian-go/keyring" "github.com/open-cli-collective/atlassian-go/prompt" @@ -293,11 +294,12 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail, v.Println("Testing connection...") client, err := api.New(api.ClientConfig{ - URL: cfg.URL, - Email: cfg.Email, - APIToken: cfg.APIToken, - AuthMethod: cfg.AuthMethod, - CloudID: cfg.CloudID, + URL: cfg.URL, + Email: cfg.Email, + APIToken: cfg.APIToken, + AuthMethod: cfg.AuthMethod, + CloudID: cfg.CloudID, + GatewayBaseURL: client.GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"), }) if err != nil { return fmt.Errorf("creating client: %w", err) diff --git a/tools/jtk/internal/cmd/root/root.go b/tools/jtk/internal/cmd/root/root.go index f6af0ad4..2934987e 100644 --- a/tools/jtk/internal/cmd/root/root.go +++ b/tools/jtk/internal/cmd/root/root.go @@ -12,6 +12,7 @@ import ( cccredstore "github.com/open-cli-collective/cli-common/credstore" "github.com/open-cli-collective/atlassian-go/artifact" + "github.com/open-cli-collective/atlassian-go/client" "github.com/open-cli-collective/atlassian-go/keyring" "github.com/open-cli-collective/atlassian-go/present" "github.com/open-cli-collective/atlassian-go/version" @@ -93,12 +94,13 @@ func (o *Options) APIClient() (*api.Client, error) { return nil, err } c, err := api.New(api.ClientConfig{ - URL: config.GetURL(), - Email: config.GetEmail(), - APIToken: token, - Verbose: o.Verbose, - AuthMethod: config.GetAuthMethod(), - CloudID: config.GetCloudID(), + URL: config.GetURL(), + Email: config.GetEmail(), + APIToken: token, + Verbose: o.Verbose, + AuthMethod: config.GetAuthMethod(), + CloudID: config.GetCloudID(), + GatewayBaseURL: client.GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"), }) if err != nil { return nil, err diff --git a/tools/jtk/internal/cmd/root/root_test.go b/tools/jtk/internal/cmd/root/root_test.go index c10ad1ef..6948dd2e 100644 --- a/tools/jtk/internal/cmd/root/root_test.go +++ b/tools/jtk/internal/cmd/root/root_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/open-cli-collective/atlassian-go/artifact" + "github.com/open-cli-collective/atlassian-go/auth" + "github.com/open-cli-collective/atlassian-go/credtest" "github.com/open-cli-collective/atlassian-go/present" "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/atlassian-go/view" @@ -219,3 +221,16 @@ func TestVersion_BareOutput(t *testing.T) { t.Errorf("version output should be semver or 'dev', got %q", got) } } + +func TestOptions_APIClient_UsesGatewayOverride(t *testing.T) { + credtest.Hermetic(t) + t.Setenv("JIRA_URL", "https://example.atlassian.net") + t.Setenv("JIRA_AUTH_METHOD", auth.AuthMethodBearer) + t.Setenv("JIRA_CLOUD_ID", "cloud-123") + t.Setenv("JIRA_API_TOKEN", "token") + t.Setenv("JIRA_GATEWAY_BASE_URL", "https://gateway.example/") + opts := &Options{} + c, err := opts.APIClient() + testutil.RequireNoError(t, err) + testutil.Equal(t, "https://gateway.example/ex/jira/cloud-123/rest/api/3", c.BaseURL) +}