Skip to content
Open
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
47 changes: 24 additions & 23 deletions appdata/appdata.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package appdata

import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"

"github.com/adrg/xdg"
Expand All @@ -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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this was only Config.Token and not being used anywhere, (token was being managed in amp/jwt.json by clerk/clerk.go, this particular changeset doesn't break any backwards compatibility for this config

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
Expand All @@ -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)
Expand All @@ -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
Expand Down
144 changes: 144 additions & 0 deletions appdata/appdata_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 18 additions & 4 deletions clerk/clerk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -91,7 +93,7 @@ func GetClerkRootURL() string {
return clerkRoot
}

return vars.ClerkRootURL
return flags.GetRegion().RegionalizeURL(vars.ClerkRootURL)
}

func GetSessionURL(data *LoginData) string {
Expand All @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions clerk/clerk_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
25 changes: 25 additions & 0 deletions cmd/get_region.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 2 additions & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down
Loading
Loading