Skip to content
Merged
2 changes: 1 addition & 1 deletion cmd/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var apiKeyCmd = &cobra.Command{
Use: "api-key",
Short: "Validate your API key",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
cfg, err := loadConfig()
if err != nil {
return err
}
Expand Down
64 changes: 64 additions & 0 deletions cmd/auth_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package cmd

import (
"fmt"

"github.com/loops-so/cli/internal/config"
"github.com/spf13/cobra"
)

var authListCmd = &cobra.Command{
Use: "list",
Short: "List stored API keys",
RunE: func(cmd *cobra.Command, args []string) error {
entries, activeTeam, err := runAuthList()
if err != nil {
return err
}

if isJSONOutput() {
type jsonEntry struct {
Name string `json:"name"`
APIKey string `json:"apiKey"`
Active bool `json:"active"`
}
out := make([]jsonEntry, len(entries))
for i, e := range entries {
out[i] = jsonEntry{e.Name, maskKey(e.APIKey), e.Name == activeTeam}
}
return printJSON(cmd.OutOrStdout(), out)
}

if len(entries) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No keys stored.")
return nil
}

w := newTableWriter(cmd.OutOrStdout())
fmt.Fprintln(w, "NAME\tAPI KEY\tACTIVE")
for _, e := range entries {
active := ""
if e.Name == activeTeam {
active = "*"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", e.Name, maskKey(e.APIKey), active)
}
return w.Flush()
},
}

func runAuthList() ([]config.KeyEntry, string, error) {
entries, err := config.ListKeys()
if err != nil {
return nil, "", err
}
pc, err := config.LoadPersistentConfig()
if err != nil {
return nil, "", err
}
return entries, pc.ActiveTeam, nil
}

func init() {
authCmd.AddCommand(authListCmd)
}
58 changes: 58 additions & 0 deletions cmd/auth_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cmd

import (
"testing"

"github.com/loops-so/cli/internal/config"
)

func TestRunAuthList(t *testing.T) {
t.Run("returns empty list when no keys stored", func(t *testing.T) {
mockKeyring(t)
entries, activeTeam, err := runAuthList()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 0 {
t.Errorf("got %d entries, want 0", len(entries))
}
if activeTeam != "" {
t.Errorf("got activeTeam %q, want empty", activeTeam)
}
})

t.Run("returns stored keys", func(t *testing.T) {
mockKeyring(t)
config.Save("key-abc1234", "acme")
config.Save("key-xyz5678", "work")

entries, _, err := runAuthList()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("got %d entries, want 2", len(entries))
}
if entries[0].Name != "acme" || entries[0].APIKey != "key-abc1234" {
t.Errorf("entry 0: got {%q, %q}, want {acme, key-abc1234}", entries[0].Name, entries[0].APIKey)
}
if entries[1].Name != "work" || entries[1].APIKey != "key-xyz5678" {
t.Errorf("entry 1: got {%q, %q}, want {work, key-xyz5678}", entries[1].Name, entries[1].APIKey)
}
})

t.Run("returns active team", func(t *testing.T) {
mockKeyring(t)
config.Save("key-abc1234", "acme")
config.Save("key-xyz5678", "work")
config.SetActiveTeam("acme")

_, activeTeam, err := runAuthList()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if activeTeam != "acme" {
t.Errorf("got activeTeam %q, want %q", activeTeam, "acme")
}
})
}
19 changes: 15 additions & 4 deletions cmd/auth_login.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"os"
"strings"
Expand All @@ -11,10 +12,16 @@ import (
"golang.org/x/term"
)

var loginName string

var loginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate with your Loops API key",
RunE: func(cmd *cobra.Command, args []string) error {
if loginName == "" {
return errors.New("use --name to give this key a name (e.g. loops auth login --name my-team)")
}

fmt.Fprint(os.Stderr, "Enter your API key: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
Expand All @@ -27,30 +34,34 @@ var loginCmd = &cobra.Command{
return fmt.Errorf("API key cannot be empty")
}

result, err := runAuthLogin(apiKey)
result, err := runAuthLogin(apiKey, loginName)
if err != nil {
return err
}

if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), Result{Success: true, Message: fmt.Sprintf("Authenticated as team: %s", result.TeamName)})
}
fmt.Fprintf(cmd.OutOrStdout(), "API key saved. Authenticated as team: %s\n", result.TeamName)
fmt.Fprintf(cmd.OutOrStdout(), "API key saved as %q. Authenticated as team: %s\n", loginName, result.TeamName)
return nil
},
}

func runAuthLogin(apiKey string) (*api.APIKeyResponse, error) {
func runAuthLogin(apiKey, name string) (*api.APIKeyResponse, error) {
if name == "" {
return nil, errors.New("use --name to give this key a name")
}
result, err := api.NewClient(config.EndpointURL(), apiKey).GetAPIKey()
if err != nil {
return nil, fmt.Errorf("API key verification failed: %w", err)
}
if err := config.Save(apiKey); err != nil {
if err := config.Save(apiKey, name); err != nil {
return nil, err
}
return result, nil
}

func init() {
loginCmd.Flags().StringVarP(&loginName, "name", "n", "", "Name for this API key (e.g. my-team)")
authCmd.AddCommand(loginCmd)
}
12 changes: 10 additions & 2 deletions cmd/auth_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
func TestRunAuthLogin(t *testing.T) {
t.Run("saves key and returns team name", func(t *testing.T) {
serveJSON(t, http.StatusOK, `{"teamName":"Acme"}`)
result, err := runAuthLogin("test-key")
result, err := runAuthLogin("test-key", "acme")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -23,7 +23,15 @@ func TestRunAuthLogin(t *testing.T) {

t.Run("returns error on api failure", func(t *testing.T) {
serveJSON(t, http.StatusUnauthorized, `{"error":"Invalid API key"}`)
_, err := runAuthLogin("bad-key")
_, err := runAuthLogin("bad-key", "acme")
if err == nil {
t.Fatal("expected error, got nil")
}
})

t.Run("returns error when name is empty", func(t *testing.T) {
serveJSON(t, http.StatusOK, `{"teamName":"Acme"}`)
_, err := runAuthLogin("test-key", "")
if err == nil {
t.Fatal("expected error, got nil")
}
Expand Down
15 changes: 11 additions & 4 deletions cmd/auth_logout.go
Original file line number Diff line number Diff line change
@@ -1,31 +1,38 @@
package cmd

import (
"errors"
"fmt"

"github.com/loops-so/cli/internal/config"
"github.com/spf13/cobra"
)

var logoutName string

var logoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove stored Loops credentials",
RunE: func(cmd *cobra.Command, args []string) error {
if err := runAuthLogout(); err != nil {
if logoutName == "" {
return errors.New("use --name to specify which key to remove (e.g. loops auth logout --name loops-prod)")
}
if err := runAuthLogout(logoutName); err != nil {
return err
}
if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), Result{Success: true})
}
fmt.Fprintln(cmd.OutOrStdout(), "Logged out.")
fmt.Fprintf(cmd.OutOrStdout(), "Logged out of %q.\n", logoutName)
return nil
},
}

func runAuthLogout() error {
return config.Delete()
func runAuthLogout(name string) error {
return config.Delete(name)
}

func init() {
logoutCmd.Flags().StringVarP(&logoutName, "name", "n", "", "Name of the API key to remove")
authCmd.AddCommand(logoutCmd)
}
13 changes: 11 additions & 2 deletions cmd/auth_logout_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
package cmd

import (
"net/http"
"testing"
)

func TestRunAuthLogout(t *testing.T) {
t.Run("succeeds", func(t *testing.T) {
mockKeyring(t)
if err := runAuthLogout(); err != nil {
serveJSON(t, http.StatusOK, `{}`)
runAuthLogin("test-key", "acme")
if err := runAuthLogout("acme"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

t.Run("returns error when name is empty", func(t *testing.T) {
mockKeyring(t)
if err := runAuthLogout(""); err == nil {
t.Fatal("expected error, got nil")
}
})
}
31 changes: 21 additions & 10 deletions cmd/auth_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var statusCmd = &cobra.Command{
Use: "status",
Short: "Print the resolved configuration",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, keyResp, err := runAuthStatus()
cfg, keyResp, pc, err := runAuthStatus()
if err != nil {
return err
}
Expand All @@ -22,29 +22,40 @@ var statusCmd = &cobra.Command{

if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), struct {
ActiveKey string `json:"activeKey"`
APIKey string `json:"apiKey"`
EndpointURL string `json:"endpointUrl"`
TeamName string `json:"teamName"`
}{masked, cfg.EndpointURL, keyResp.TeamName})
}{pc.ActiveTeam, masked, cfg.EndpointURL, keyResp.TeamName})
}

fmt.Fprintf(cmd.OutOrStdout(), "API Key: %s\n", masked)
fmt.Fprintf(cmd.OutOrStdout(), "Endpoint: %s\n", cfg.EndpointURL)
fmt.Fprintf(cmd.OutOrStdout(), "Team: %s\n", keyResp.TeamName)
activeKey := pc.ActiveTeam
if activeKey == "" {
activeKey = "(none)"
}

fmt.Fprintf(cmd.OutOrStdout(), "Active Key: %s\n", activeKey)
fmt.Fprintf(cmd.OutOrStdout(), "API Key: %s\n", masked)
fmt.Fprintf(cmd.OutOrStdout(), "Team: %s\n", keyResp.TeamName)
fmt.Fprintf(cmd.OutOrStdout(), "Endpoint: %s\n", cfg.EndpointURL)
return nil
},
}

func runAuthStatus() (*config.Config, *api.APIKeyResponse, error) {
cfg, err := config.Load()
func runAuthStatus() (*config.Config, *api.APIKeyResponse, *config.PersistentConfig, error) {
cfg, err := loadConfig()
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
keyResp, err := api.NewClient(cfg.EndpointURL, cfg.APIKey).GetAPIKey()
if err != nil {
return nil, nil, fmt.Errorf("API key verification failed: %w", err)
return nil, nil, nil, fmt.Errorf("API key verification failed: %w", err)
}
pc, err := config.LoadPersistentConfig()
if err != nil {
return nil, nil, nil, err
}
return cfg, keyResp, nil
return cfg, keyResp, pc, nil
}

func maskKey(key string) string {
Expand Down
14 changes: 8 additions & 6 deletions cmd/auth_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ package cmd
import (
"net/http"
"testing"

"github.com/zalando/go-keyring"
)

func TestRunAuthStatus(t *testing.T) {
t.Run("returns config and team name", func(t *testing.T) {
serveJSON(t, http.StatusOK, `{"teamName":"Acme"}`)
cfg, keyResp, err := runAuthStatus()
cfg, keyResp, pc, err := runAuthStatus()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -23,19 +21,23 @@ func TestRunAuthStatus(t *testing.T) {
if keyResp.TeamName != "Acme" {
t.Errorf("got team %q, want %q", keyResp.TeamName, "Acme")
}
if pc == nil {
t.Error("expected PersistentConfig to be set")
}
})

t.Run("returns error when no key set", func(t *testing.T) {
keyring.MockInit()
_, _, err := runAuthStatus()
mockKeyring(t)
t.Setenv("LOOPS_API_KEY", "")
_, _, _, err := runAuthStatus()
if err == nil {
t.Fatal("expected error, got nil")
}
})

t.Run("returns error on api failure", func(t *testing.T) {
serveJSON(t, http.StatusUnauthorized, `{"error":"Invalid API key"}`)
_, _, err := runAuthStatus()
_, _, _, err := runAuthStatus()
if err == nil {
t.Fatal("expected error, got nil")
}
Expand Down
Loading