diff --git a/appdata/appdata.go b/appdata/appdata.go index 0be1ed5..295d1d6 100644 --- a/appdata/appdata.go +++ b/appdata/appdata.go @@ -2,7 +2,9 @@ package appdata import ( "encoding/json" + "errors" "fmt" + "io/fs" "os" "github.com/adrg/xdg" @@ -16,35 +18,34 @@ const fileName = "Ampersand/config.json" // IMPORTANT: Do not modify the JSON labels in this struct without ensuring backwards // compatibility, since those strings are written to the user's config file on their computer. type Config struct { - Token Token `json:"token"` -} - -// Token represents a JWT token. -type Token struct { - Iss string `json:"iss"` - Sub string `json:"sub"` - Aud string `json:"aud"` - Iat int `json:"iat"` - Exp int `json:"exp"` + // Region is the Ampersand deployment region the CLI talks to, e.g. "us" or "eu". + // An empty value means no region has been selected and the default ("us") applies. + Region string `json:"region"` } // Get returns the user's existing config, or an empty config if the file doesn't exist. func Get() (Config, error) { - path, err := getExistingFilePath() + path, err := configFilePath() if err != nil { return Config{}, err } data, err := os.ReadFile(path) if err != nil { - return Config{}, fmt.Errorf("can't read config file: %w", err) + if errors.Is(err, fs.ErrNotExist) { + // no config file exists yet, which is not an error: + // the caller is returned an empty Config object, and Set() will create the file + return Config{}, nil + } + + return Config{}, fmt.Errorf("can't read config file at %s: %w", path, err) } var c Config err = json.Unmarshal(data, &c) if err != nil { - return Config{}, fmt.Errorf("can't parse config: %w", err) + return Config{}, fmt.Errorf("can't parse config at %s: %w", path, err) } return c, nil @@ -69,12 +70,9 @@ func Set(config Config) error { } func setEntireConfig(config Config) error { - path, err := getExistingFilePath() + path, err := configFilePath() if err != nil { - path, err = getPathForNewFile() - if err != nil { - return fmt.Errorf("can't get path for new config file: %w", err) - } + return err } js, err := json.Marshal(config) @@ -85,12 +83,15 @@ func setEntireConfig(config Config) error { return writeFile(path, js) } -func getPathForNewFile() (string, error) { - return xdg.ConfigFile(fileName) -} +// configFilePath returns the path of the user's config file in the XDG config home, creating +// its parent directory if needed. This mirrors how clerk.GetJwtPath locates jwt.json. +func configFilePath() (string, error) { + path, err := xdg.ConfigFile(fileName) + if err != nil { + return "", fmt.Errorf("can't determine config file path: %w", err) + } -func getExistingFilePath() (string, error) { - return xdg.SearchConfigFile(fileName) + return path, nil } const perm = 0o600 // Regular file with read/write permission for owner diff --git a/appdata/appdata_test.go b/appdata/appdata_test.go new file mode 100644 index 0000000..c835ee7 --- /dev/null +++ b/appdata/appdata_test.go @@ -0,0 +1,144 @@ +package appdata + +import ( + "os" + "path/filepath" + "testing" + + "github.com/adrg/xdg" +) + +func setup(t *testing.T) string { + t.Helper() + + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + xdg.Reload() + + t.Cleanup(xdg.Reload) + + return configHome +} + +func writeConfig(t *testing.T, configHome string, contents string) { + t.Helper() + + configDir := filepath.Join(configHome, "Ampersand") + + err := os.MkdirAll(configDir, 0o700) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(filepath.Join(configDir, "config.json"), []byte(contents), 0o600) + if err != nil { + t.Fatal(err) + } +} + +//nolint:paralleltest // mutates the environment +func TestGetNoFile(t *testing.T) { + setup(t) + + config, err := Get() + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if config.Region != "" { + t.Errorf("Get() = %+v, want zero config", config) + } +} + +//nolint:paralleltest // mutates the environment +func TestGetCorruptFile(t *testing.T) { + configHome := setup(t) + writeConfig(t, configHome, `{"region":`) + + _, err := Get() + if err == nil { + t.Error("Get() error = nil, want error") + } +} + +//nolint:paralleltest // mutates the environment +func TestSetCreatesFile(t *testing.T) { + configHome := setup(t) + + err := Set(Config{Region: "eu"}) + if err != nil { + t.Fatalf("Set() error = %v", err) + } + + contents, err := os.ReadFile(filepath.Join(configHome, "Ampersand", "config.json")) + if err != nil { + t.Fatal(err) + } + + if string(contents) != `{"region":"eu"}` { + t.Errorf("config.json = %s, want %s", contents, `{"region":"eu"}`) + } +} + +//nolint:paralleltest // mutates the environment +func TestSetGetRoundTrip(t *testing.T) { + setup(t) + + err := Set(Config{Region: "eu"}) + if err != nil { + t.Fatalf("Set() error = %v", err) + } + + config, err := Get() + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if config.Region != "eu" { + t.Errorf("Get().Region = %q, want %q", config.Region, "eu") + } +} + +//nolint:paralleltest // mutates the environment +func TestSetOverwrites(t *testing.T) { + setup(t) + + err := Set(Config{Region: "eu"}) + if err != nil { + t.Fatalf("Set() error = %v", err) + } + + err = Set(Config{Region: "us"}) + if err != nil { + t.Fatalf("Set() error = %v", err) + } + + config, err := Get() + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if config.Region != "us" { + t.Errorf("Get().Region = %q, want %q", config.Region, "us") + } +} + +//nolint:paralleltest // mutates the environment +func TestSetCorruptFile(t *testing.T) { + configHome := setup(t) + writeConfig(t, configHome, `{"region":`) + + err := Set(Config{Region: "us"}) + if err == nil { + t.Fatal("Set() error = nil, want error") + } + + contents, err := os.ReadFile(filepath.Join(configHome, "Ampersand", "config.json")) + if err != nil { + t.Fatal(err) + } + + if string(contents) != `{"region":` { + t.Errorf("config.json = %s, want it unchanged", contents) + } +} diff --git a/clerk/clerk.go b/clerk/clerk.go index 9a55cb8..41ed9f1 100644 --- a/clerk/clerk.go +++ b/clerk/clerk.go @@ -15,7 +15,9 @@ import ( "github.com/adrg/xdg" "github.com/alexkappa/mustache" + "github.com/amp-labs/cli/flags" "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/region" "github.com/amp-labs/cli/utils" "github.com/amp-labs/cli/vars" "github.com/clerkinc/clerk-sdk-go/clerk" @@ -91,7 +93,7 @@ func GetClerkRootURL() string { return clerkRoot } - return vars.ClerkRootURL + return flags.GetRegion().RegionalizeURL(vars.ClerkRootURL) } func GetSessionURL(data *LoginData) string { @@ -102,14 +104,26 @@ func GetSessionURL(data *LoginData) string { return fmt.Sprintf(ClientSessionPathDev, GetClerkRootURL(), data.Token) } +// GetJwtFile returns the config path of the file holding the stored JWT. +// +// Credentials are scoped to the region and stage they were issued for. +// The US paths are unchanged from before regions existed, to ensure backwards compatibility. func GetJwtFile() string { stage := utils.GetStage() + reg := flags.GetRegion() - if stage == "prod" { - return "amp/jwt.json" + // default assumption is US region and prod stage, for backwards compatibility + name := "jwt" + + if reg != region.Default { + name += "-" + string(reg) + } + + if stage != "prod" { + name += "-" + stage } - return fmt.Sprintf("amp/jwt-%s.json", stage) + return "amp/" + name + ".json" } // GetJwtPath returns the path to the jwt.json file where the JWT token is stored. diff --git a/clerk/clerk_test.go b/clerk/clerk_test.go new file mode 100644 index 0000000..49cf042 --- /dev/null +++ b/clerk/clerk_test.go @@ -0,0 +1,68 @@ +package clerk + +import ( + "testing" + + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/region" +) + +//nolint:paralleltest // mutates viper and the environment +func TestGetJwtFile(t *testing.T) { + tests := []struct { + name string + region region.Region + stage string + want string + }{ + {name: "default prod", region: region.Default, stage: "prod", want: "amp/jwt.json"}, + {name: "us prod", region: region.US, stage: "prod", want: "amp/jwt.json"}, + {name: "us staging", region: region.US, stage: "staging", want: "amp/jwt-staging.json"}, + {name: "us dev", region: region.US, stage: "dev", want: "amp/jwt-dev.json"}, + {name: "eu prod", region: region.EU, stage: "prod", want: "amp/jwt-eu.json"}, + {name: "eu staging", region: region.EU, stage: "staging", want: "amp/jwt-eu-staging.json"}, + {name: "eu dev", region: region.EU, stage: "dev", want: "amp/jwt-eu-dev.json"}, + } + + t.Cleanup(func() { flags.SetRegion(region.Default) }) + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv("AMP_STAGE_OVERRIDE", testCase.stage) + flags.SetRegion(testCase.region) + + got := GetJwtFile() + if got != testCase.want { + t.Errorf("GetJwtFile() = %q, want %q", got, testCase.want) + } + }) + } +} + +//nolint:paralleltest // mutates viper and the environment +func TestGetJwtFileUnique(t *testing.T) { + t.Cleanup(func() { flags.SetRegion(region.Default) }) + + seen := make(map[string]string) + + for _, name := range region.Known() { + for _, stage := range []string{"prod", "staging", "dev", "local"} { + reg, err := region.Parse(name) + if err != nil { + t.Fatalf("Parse(%q) error = %v", name, err) + } + + t.Setenv("AMP_STAGE_OVERRIDE", stage) + flags.SetRegion(reg) + + path := GetJwtFile() + key := name + "/" + stage + + if other, ok := seen[path]; ok { + t.Errorf("GetJwtFile() = %q for both %s and %s", path, other, key) + } + + seen[path] = key + } + } +} diff --git a/cmd/get_region.go b/cmd/get_region.go new file mode 100644 index 0000000..0692e27 --- /dev/null +++ b/cmd/get_region.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/region" + "github.com/spf13/cobra" +) + +var getRegionCmd = &cobra.Command{ //nolint:gochecknoglobals + Use: "get:region", + Short: "Print the region the CLI is talking to", + Long: "Print the region the CLI is talking to.\n\n" + + "This is the --region flag if given, otherwise the region saved by 'amp set:region', " + + "otherwise the " + region.EnvVar + " environment variable, otherwise defaults to " + string(region.Default) + ".", + Run: func(cmd *cobra.Command, args []string) { + // The root command's PersistentPreRun has already resolved this, and would + // have exited if it could not. + logger.Info(string(flags.GetRegion())) + }, +} + +func init() { + rootCmd.AddCommand(getRegionCmd) +} diff --git a/cmd/login.go b/cmd/login.go index f71d34b..30f08e1 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -12,6 +12,7 @@ import ( "time" "github.com/amp-labs/cli/clerk" + "github.com/amp-labs/cli/flags" "github.com/amp-labs/cli/logger" "github.com/amp-labs/cli/vars" "github.com/spf13/cobra" @@ -36,7 +37,7 @@ func getLoginURL() string { return loginURL } - return vars.LoginURL + return flags.GetRegion().RegionalizeURL(vars.LoginURL) } func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { diff --git a/cmd/my_info.go b/cmd/my_info.go index 4ce3c04..f245d61 100644 --- a/cmd/my_info.go +++ b/cmd/my_info.go @@ -22,7 +22,7 @@ var myInfoCmd = &cobra.Command{ //nolint:gochecknoglobals Run: func(cmd *cobra.Command, args []string) { rootURL, ok := os.LookupEnv("AMP_API_URL") if !ok { - rootURL = vars.ApiURL + rootURL = flags.GetRegion().RegionalizeURL(vars.ApiURL) } client := &request.APIClient{ diff --git a/cmd/root.go b/cmd/root.go index a6b72d2..083d8a6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,8 +1,11 @@ package cmd import ( + "os" + "github.com/amp-labs/cli/flags" "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/region" "github.com/spf13/cobra" ) @@ -11,11 +14,47 @@ var rootCmd = &cobra.Command{ //nolint:gochecknoglobals Use: "amp", Short: "Ampersand CLI", Long: "The Ampersand CLI allows you to interact with the Ampersand platform.", + + // Resolve the region before any command runs so that a malformed --region or an + // unreadable config fails immediately, before running any command. + PersistentPreRun: func(cmd *cobra.Command, args []string) { + flagValue, _ := cmd.Root().PersistentFlags().GetString(region.FlagName) + + selected, err := region.Resolve(flagValue) + if err != nil { + logger.FatalErr("Unable to determine the region", err) + } + + flags.SetRegion(selected) + warnIfEnvIgnored(flagValue, selected) + }, + // Uncomment the following line if your bare application // has an action associated with it: // Run: func(cmd *cobra.Command, args []string) { }, } +// warnIfEnvIgnored tells the user when AMP_REGION is set, but will be ignored in favor of the region +// saved previously by 'amp set:region'. +func warnIfEnvIgnored(flagValue string, selected region.Region) { + if flagValue != "" { + return + } + + fromEnv := os.Getenv(region.EnvVar) + if fromEnv == "" { + return + } + + parsed, err := region.Parse(fromEnv) + if err == nil && parsed == selected { + return + } + + logger.Warnf("ignoring %s=%q; using the region saved by 'amp set:region' (%s) instead.", + region.EnvVar, fromEnv, selected) +} + // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { diff --git a/cmd/set_region.go b/cmd/set_region.go new file mode 100644 index 0000000..a21e588 --- /dev/null +++ b/cmd/set_region.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "strings" + + "github.com/amp-labs/cli/appdata" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/region" + "github.com/amp-labs/cli/vars" + "github.com/spf13/cobra" +) + +var setRegionCmd = &cobra.Command{ //nolint:gochecknoglobals + Use: "set:region ", + Short: `Save the region the CLI should talk to (default "` + string(region.Default) + `")`, + Long: "Save the region the CLI should talk to, e.g. " + strings.Join(region.Known(), " or ") + ". " + + "Other names are accepted as-is. Credentials are scoped to the region they were issued for, " + + "so after switching you may need to run 'amp login' again.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + selected, err := region.Parse(args[0]) + if err != nil { + logger.FatalErr("Unable to set the region", err) + } + + err = appdata.Set(appdata.Config{Region: string(selected)}) + if err != nil { + logger.FatalErr("Unable to save the region", err) + } + + logger.Infof("Region set to %s.", selected) + + if !region.IsKnown(selected) { + logger.Warnf("this version of amp does not know region %q; requests will go to %s.", + selected, selected.RegionalizeURL(vars.ApiURL)) + } + }, +} + +func init() { + rootCmd.AddCommand(setRegionCmd) +} diff --git a/flags/config.go b/flags/config.go index 7f4ec32..fd1468e 100644 --- a/flags/config.go +++ b/flags/config.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/amp-labs/cli/region" "github.com/amp-labs/cli/utils" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -19,6 +20,9 @@ func Init(rootCmd *cobra.Command) error { rootCmd.PersistentFlags().BoolP("debug", "d", false, "Enable debug logging mode, defaults to false.") rootCmd.PersistentFlags().StringP("project", "p", "", "Ampersand project name or ID") rootCmd.PersistentFlags().StringP("key", "k", "", "Ampersand API key") + rootCmd.PersistentFlags().StringP(region.FlagName, "r", "", + "Ampersand region to talk to, e.g. "+strings.Join(region.Known(), " or ")+". "+ + "If never set, defaults to "+string(region.Default)+".") err := viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) if err != nil { @@ -40,7 +44,9 @@ func Init(rootCmd *cobra.Command) error { panic(err) } - return nil + // Unlike --key, the region's environment variable (AMP_REIGON) is not bound here, + // as viper would then rank it above the 'amp set:region' saved/config region, when we want it ranked below + return viper.BindPFlag(region.FlagName, rootCmd.PersistentFlags().Lookup(region.FlagName)) } // InitAndBindFormatFlag initializes and binds the format flag to the provided command. @@ -92,3 +98,18 @@ func GetProjectOrFail() string { func GetAPIKey() string { return viper.GetString("key") } + +// GetRegion returns the region this process talks to. +// PersistentPreRun resolves it, calls SetRegion, and then this function returns that value. +func GetRegion() region.Region { + if resolved := viper.GetString(region.FlagName); resolved != "" { + return region.Region(resolved) + } + + return region.Default +} + +// SetRegion records the resolved region for the rest of the process. +func SetRegion(selected region.Region) { + viper.Set(region.FlagName, string(selected)) +} diff --git a/logger/logger.go b/logger/logger.go index 0eb1f70..a15af7c 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -16,6 +16,16 @@ func Infof(msg string, a ...any) { Info(fmt.Sprintf(msg, a...)) } +// Warn prints a warning to stderr, so that it never mixes into output a caller may be +// piping or parsing (e.g. 'amp get:region', or a list command with --format json). +func Warn(msg string) { + fmt.Fprintf(os.Stderr, "Warning: %s\n", msg) +} + +func Warnf(msg string, a ...any) { + Warn(fmt.Sprintf(msg, a...)) +} + func Debug(msg string) { if flags.GetDebugMode() { fmt.Fprintf(os.Stdout, "%s DEBUG: %s\n", time.Now().Format(time.RFC3339), msg) diff --git a/region/region.go b/region/region.go new file mode 100644 index 0000000..52f855b --- /dev/null +++ b/region/region.go @@ -0,0 +1,187 @@ +// Package region resolves which Ampersand deployment region the CLI talks to, and +// rewrites Ampersand URLs into their equivalent in that region. +package region + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "regexp" + "sort" + "strings" + + "github.com/amp-labs/cli/appdata" +) + +// Region is an Ampersand deployment region. +type Region string + +const ( + US Region = "us" //nolint:varnamelen // read package-qualified, as region.US + EU Region = "eu" //nolint:varnamelen // read package-qualified, as region.EU + + // Default is the region used when none has been selected. + Default = US +) + +// FlagName is the name of the persistent flag and viper key that select the region. +// The flags package binds both; see flags.GetRegion. +const FlagName = "region" + +// EnvVar is the environment variable that selects the region when neither --region nor +// 'amp set:region' has. It is deliberately not bound to viper, which would rank it above the +// saved region; Resolve reads it directly instead. +const EnvVar = "AMP_REGION" + +// baseDomain is the top-level domain that every Ampersand hostname sits under. +const baseDomain = "withampersand.com" + +// known is the set of regions supported at the time of this build of the CLI. +var known = map[Region]struct{}{ //nolint:gochecknoglobals + US: {}, + EU: {}, +} + +// Known lists the regions supported at the time of this build of the CLI. +// Default ("us") first and the rest sorted. It is for help text and warnings only. +// Parse accepts any region name, so when new regions launch users can set them without needing +// to upgrade to a newer CLI build. +func Known() []string { + others := make([]string, 0, len(known)) + + for region := range known { + if region == Default { + continue + } + + others = append(others, string(region)) + } + + sort.Strings(others) + + return append([]string{string(Default)}, others...) +} + +// IsKnown reports whether this region was supported at the time this CLI was built. See Known. +func IsKnown(region Region) bool { + _, ok := known[region] + + return ok +} + +// ErrInvalid is returned when a region name could not be used as a DNS label. +var ErrInvalid = errors.New("invalid region name") + +// labelPattern is what a region name must look like to serve as a DNS label: lowercase +// letters, digits and hyphens, starting with a letter and not ending in a hyphen. +var labelPattern = regexp.MustCompile(`^[a-z](?:[a-z0-9-]*[a-z0-9])?$`) //nolint:gochecknoglobals + +// maxLabelLen is the DNS limit on the length of a single label. +const maxLabelLen = 63 + +// Parse normalizes a region name, and checks that it is a well-formed DNS label. +// It deliberately does not check the name against Known, so that as new regions are supported, +// users can set them without having to upgrade the build of their CLI. +// +// An empty name is not valid: callers that want the fallback should use Resolve. +func Parse(name string) (Region, error) { + region := Region(strings.ToLower(strings.TrimSpace(name))) + + if len(region) > maxLabelLen || !labelPattern.MatchString(string(region)) { + return "", fmt.Errorf("%w: %q (e.g. %s)", ErrInvalid, name, strings.Join(Known(), ", ")) + } + + return region, nil +} + +// RegionalizeURL rewrites an Ampersand hostname into its equivalent in this region, eg: +// +// clerk.withampersand.com -> clerk.eu.withampersand.com +// cli-signin.withampersand.com -> cli-signin.eu.withampersand.com +// staging-api.withampersand.com -> staging-api.eu.withampersand.com +func (region Region) RegionalizeURL(rawURL string) string { + label := region.label() + if label == "" { + // if US region, no label, return original URL + return rawURL + } + + parsed, err := url.Parse(rawURL) + if err != nil { + // on parsing error, fallback to original URL + return rawURL + } + + baseURLSuffix := "." + baseDomain + + host := parsed.Hostname() + if !strings.HasSuffix(host, baseURLSuffix) { + // if not a *.withampersand.com URL, return original URL + return rawURL + } + + prefix := strings.TrimSuffix(host, baseURLSuffix) + if prefix == label || strings.HasSuffix(prefix, "."+label) { + // if already regionalized, return original URL + return rawURL + } + + // otherwise, regionalize the *.withampersand.com URL + regionalHost := prefix + "." + label + "." + baseDomain + if port := parsed.Port(); port != "" { + regionalHost = net.JoinHostPort(regionalHost, port) + } + + parsed.Host = regionalHost + + return parsed.String() +} + +// label is the DNS label that identifies the region in an Ampersand hostname. +// +// The US is the original, label-less deployment (api.withampersand.com), so it has none. +// Every other region inserts its name ahead of the base domain (api.eu.withampersand.com). +func (region Region) label() string { + if region == US { + return "" + } + + return string(region) +} + +// Resolve returns the region to use, by priority: +// 1. --region +// 2. 'amp set:region' (config/appdata file) +// 3. AMP_REGION +// 4. region.Default ("us") +// +// The saved region deliberately outranks AMP_REGION, unlike viper's usual env-over-config order. +// +// A missing config file is not an error: it means no region has been saved yet. +// A malformed region name, or a config file that exists but cannot be read, is. +func Resolve(flagValue string) (Region, error) { + // --region invokes this function with the provided value + if flagValue != "" { + return Parse(flagValue) + } + + // otherwise, check if the config file has a region set + config, err := appdata.Get() + if err != nil { + return "", fmt.Errorf("could not read the saved region: %w", err) + } + + if config.Region != "" { + return Parse(config.Region) + } + + // otherwise, check the environment variable + if fromEnv := os.Getenv(EnvVar); fromEnv != "" { + return Parse(fromEnv) + } + + // otherwise, default to US + return Default, nil +} diff --git a/region/region_test.go b/region/region_test.go new file mode 100644 index 0000000..f647bda --- /dev/null +++ b/region/region_test.go @@ -0,0 +1,297 @@ +package region + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/adrg/xdg" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want Region + wantErr bool + }{ + {name: "us", input: "us", want: US}, + {name: "eu", input: "eu", want: EU}, + {name: "uppercase", input: "EU", want: EU}, + {name: "mixed case", input: "eU", want: EU}, + {name: "whitespace", input: " eu ", want: EU}, + {name: "unknown region", input: "ap", want: Region("ap")}, + {name: "hyphen", input: "eu-west", want: Region("eu-west")}, + {name: "digit", input: "eu2", want: Region("eu2")}, + {name: "max length", input: strings.Repeat("a", maxLabelLen), want: Region(strings.Repeat("a", maxLabelLen))}, + {name: "empty", input: "", wantErr: true}, + {name: "inner space", input: "eu west", wantErr: true}, + {name: "leading hyphen", input: "-eu", wantErr: true}, + {name: "trailing hyphen", input: "eu-", wantErr: true}, + {name: "leading digit", input: "1eu", wantErr: true}, + {name: "punctuation", input: "eu!", wantErr: true}, + {name: "dot", input: "eu.west", wantErr: true}, + {name: "too long", input: strings.Repeat("a", maxLabelLen+1), wantErr: true}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, err := Parse(testCase.input) + + if testCase.wantErr { + if !errors.Is(err, ErrInvalid) { + t.Errorf("Parse(%q) error = %v, want ErrInvalid", testCase.input, err) + } + + return + } + + if err != nil { + t.Fatalf("Parse(%q) error = %v", testCase.input, err) + } + + if got != testCase.want { + t.Errorf("Parse(%q) = %q, want %q", testCase.input, got, testCase.want) + } + }) + } +} + +func TestKnown(t *testing.T) { + t.Parallel() + + got := Known() + want := []string{"us", "eu"} + + if len(got) != len(want) { + t.Fatalf("Known() = %v, want %v", got, want) + } + + for i := range want { + if got[i] != want[i] { + t.Errorf("Known() = %v, want %v", got, want) + } + } +} + +func TestKnownOrder(t *testing.T) { + t.Parallel() + + got := Known() + + if len(got) == 0 { + t.Fatal("Known() is empty") + } + + if got[0] != string(Default) { + t.Errorf("Known()[0] = %q, want %q", got[0], Default) + } + + if !sort.StringsAreSorted(got[1:]) { + t.Errorf("Known()[1:] = %v, want sorted", got[1:]) + } +} + +func TestIsKnown(t *testing.T) { + t.Parallel() + + for _, name := range Known() { + if !IsKnown(Region(name)) { + t.Errorf("IsKnown(%q) = false, want true", name) + } + } + + if IsKnown(Region("ap")) { + t.Error(`IsKnown("ap") = true, want false`) + } +} + +func TestRegionalizeURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawURL string + region Region + want string + }{ + { + name: "us", + rawURL: "https://api.withampersand.com", + region: US, + want: "https://api.withampersand.com", + }, + { + name: "eu", + rawURL: "https://api.withampersand.com", + region: EU, + want: "https://api.eu.withampersand.com", + }, + { + name: "unknown region", + rawURL: "https://api.withampersand.com", + region: Region("ap"), + want: "https://api.ap.withampersand.com", + }, + { + name: "stage prefix", + rawURL: "https://staging-api.withampersand.com", + region: EU, + want: "https://staging-api.eu.withampersand.com", + }, + { + name: "sign-in host", + rawURL: "https://cli-signin.withampersand.com", + region: EU, + want: "https://cli-signin.eu.withampersand.com", + }, + { + name: "path and query", + rawURL: "https://api.withampersand.com/v1?debug=true", + region: EU, + want: "https://api.eu.withampersand.com/v1?debug=true", + }, + { + name: "port", + rawURL: "https://api.withampersand.com:8443", + region: EU, + want: "https://api.eu.withampersand.com:8443", + }, + { + name: "already regionalized", + rawURL: "https://api.eu.withampersand.com", + region: EU, + want: "https://api.eu.withampersand.com", + }, + { + name: "localhost", + rawURL: "http://localhost:8080", + region: EU, + want: "http://localhost:8080", + }, + { + name: "loopback", + rawURL: "http://127.0.0.1:4010", + region: EU, + want: "http://127.0.0.1:4010", + }, + { + name: "other domain", + rawURL: "https://api.example.com", + region: EU, + want: "https://api.example.com", + }, + { + name: "bare domain", + rawURL: "https://withampersand.com", + region: EU, + want: "https://withampersand.com", + }, + { + name: "suffix lookalike", + rawURL: "https://api.notwithampersand.com", + region: EU, + want: "https://api.notwithampersand.com", + }, + { + name: "unparseable", + rawURL: "://not a url", + region: EU, + want: "://not a url", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got := testCase.region.RegionalizeURL(testCase.rawURL) + if got != testCase.want { + t.Errorf("RegionalizeURL(%q) = %q, want %q", testCase.rawURL, got, testCase.want) + } + }) + } +} + +func setupConfig(t *testing.T, contents string) { + t.Helper() + + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + xdg.Reload() + + t.Cleanup(xdg.Reload) + + if contents == "" { + return + } + + configDir := filepath.Join(configHome, "Ampersand") + + err := os.MkdirAll(configDir, 0o700) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(filepath.Join(configDir, "config.json"), []byte(contents), 0o600) + if err != nil { + t.Fatal(err) + } +} + +//nolint:paralleltest // mutates the environment +func TestResolve(t *testing.T) { + tests := []struct { + name string + config string + env string + flagValue string + want Region + wantErr bool + }{ + {name: "default", config: "", flagValue: "", want: Default}, + {name: "saved", config: `{"region":"eu"}`, flagValue: "", want: EU}, + {name: "env", config: "", env: "eu", flagValue: "", want: EU}, + {name: "flag over saved", config: `{"region":"eu"}`, flagValue: "us", want: US}, + {name: "flag over env", config: "", env: "eu", flagValue: "us", want: US}, + {name: "saved over env", config: `{"region":"eu"}`, env: "ap", flagValue: "", want: EU}, + {name: "flag unknown", config: "", flagValue: "ap", want: Region("ap")}, + {name: "flag malformed", config: "", flagValue: "eu west", wantErr: true}, + {name: "saved malformed", config: `{"region":"eu west"}`, flagValue: "", wantErr: true}, + {name: "env malformed", config: "", env: "eu west", flagValue: "", wantErr: true}, + {name: "corrupt config", config: `{"region":`, flagValue: "", wantErr: true}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + setupConfig(t, testCase.config) + // Always set, so that AMP_REGION in the developer's own shell cannot leak in. + t.Setenv(EnvVar, testCase.env) + + got, err := Resolve(testCase.flagValue) + + if testCase.wantErr { + if err == nil { + t.Errorf("Resolve(%q) = %q, want error", testCase.flagValue, got) + } + + return + } + + if err != nil { + t.Fatalf("Resolve(%q) error = %v", testCase.flagValue, err) + } + + if got != testCase.want { + t.Errorf("Resolve(%q) = %q, want %q", testCase.flagValue, got, testCase.want) + } + }) + } +} diff --git a/request/api.go b/request/api.go index e7a9ecd..9d1af38 100644 --- a/request/api.go +++ b/request/api.go @@ -7,6 +7,7 @@ import ( "os" "github.com/amp-labs/cli/clerk" + "github.com/amp-labs/cli/flags" "github.com/amp-labs/cli/logger" "github.com/amp-labs/cli/openapi" "github.com/amp-labs/cli/vars" @@ -31,7 +32,7 @@ func NewAPIClient(projectId string, key *string) *APIClient { // For testing reasons, sometimes it's useful to override the API endpoint rootURL, ok := os.LookupEnv("AMP_API_URL") if !ok { - rootURL = vars.ApiURL + rootURL = flags.GetRegion().RegionalizeURL(vars.ApiURL) } return &APIClient{