diff --git a/core/cli/auth.go b/core/cli/auth.go new file mode 100644 index 0000000..24e9ac1 --- /dev/null +++ b/core/cli/auth.go @@ -0,0 +1,152 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/Permify/permify-cli/core/credentials" + "github.com/Permify/permify-cli/core/logger" + "github.com/spf13/cobra" +) + +// authPersistentPreRun is a stripped-down replacement for the root +// PersistentPreRun: it sets up logging based on --debug but skips the +// permctl config-file check so login/logout/whoami remain usable before +// `permctl configure` has ever been run. +func authPersistentPreRun(cmd *cobra.Command, _ []string) { + debugEnabled, _ := cmd.Flags().GetBool("debug") + logger.Update(debugEnabled) +} + +// LoginCmd persists endpoint / token / mTLS material under the active +// profile. It writes to the credentials file with 0600 permissions; only +// the fields supplied on the command line are updated, so the command can +// also be used to rotate a single value (e.g. `permctl login --token new`). +func LoginCmd() *cobra.Command { + var ( + endpoint string + token string + certPath string + certKey string + ) + cmd := &cobra.Command{ + Use: "login", + Short: "store permify credentials (endpoint, token, mTLS cert/key)", + Long: `Persist credentials used by permctl when talking to a Permify server. + +Credentials are stored as YAML at $PERMIFY_CREDENTIALS_FILE if set, +otherwise at $HOME/.permctl-credentials.yaml, with 0600 file permissions. +Entries are keyed by profile (default: "default").`, + // Override the root PersistentPreRun: login must work before a + // permctl config file exists. + PersistentPreRun: authPersistentPreRun, + RunE: func(cmd *cobra.Command, _ []string) error { + if endpoint == "" && token == "" && certPath == "" && certKey == "" { + return errors.New("at least one of --endpoint, --token, --cert-path, --cert-key must be provided") + } + profile, _ := cmd.Flags().GetString("profile") + + // Merge with any existing entry so single-flag rotation works. + existing, err := credentials.Load(profile) + if err != nil && !errors.Is(err, credentials.ErrNotFound) { + return err + } + if existing == nil { + existing = &credentials.Credentials{} + } + if endpoint != "" { + existing.Endpoint = endpoint + } + if token != "" { + existing.Token = token + } + if certPath != "" { + existing.CertPath = certPath + } + if certKey != "" { + existing.CertKey = certKey + } + + if err := credentials.Save(profile, existing); err != nil { + return err + } + path, _ := credentials.GetCredentialsPath() + logger.Log.Info("credentials stored", "profile", profile, "file", path) + return nil + }, + } + cmd.Flags().StringVar(&endpoint, "endpoint", "", "permify gRPC endpoint (host:port)") + cmd.Flags().StringVar(&token, "token", "", "bearer token for authenticated calls") + cmd.Flags().StringVar(&certPath, "cert-path", "", "path to client TLS certificate (PEM)") + cmd.Flags().StringVar(&certKey, "cert-key", "", "path to client TLS key (PEM)") + return cmd +} + +// LogoutCmd deletes stored credentials for the active profile. If no other +// profiles remain in the file, the file itself is removed. +func LogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "remove stored permify credentials for the active profile", + PersistentPreRun: authPersistentPreRun, + RunE: func(cmd *cobra.Command, _ []string) error { + profile, _ := cmd.Flags().GetString("profile") + if err := credentials.Delete(profile); err != nil { + return err + } + logger.Log.Info("credentials removed", "profile", profile) + return nil + }, + } +} + +// WhoamiCmd prints the stored credentials for the active profile, masking +// the token so the value is safe to share in screenshots/bug reports. +func WhoamiCmd() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "show stored permify credentials for the active profile", + PersistentPreRun: authPersistentPreRun, + RunE: func(cmd *cobra.Command, _ []string) error { + profile, _ := cmd.Flags().GetString("profile") + creds, err := credentials.Load(profile) + if err != nil { + if errors.Is(err, credentials.ErrNotFound) { + path, _ := credentials.GetCredentialsPath() + return fmt.Errorf("no credentials stored for profile %q (file: %s) - run `permctl login` to set them", profile, path) + } + return err + } + path, _ := credentials.GetCredentialsPath() + fmt.Printf("profile: %s\n", profile) + fmt.Printf("file: %s\n", path) + fmt.Printf("endpoint: %s\n", display(creds.Endpoint)) + fmt.Printf("token: %s\n", maskToken(creds.Token)) + fmt.Printf("cert_path: %s\n", display(creds.CertPath)) + fmt.Printf("cert_key: %s\n", display(creds.CertKey)) + return nil + }, + } +} + +// display returns "(unset)" for empty values so the whoami output makes the +// difference between unset and intentional empty obvious. +func display(s string) string { + if s == "" { + return "(unset)" + } + return s +} + +// maskToken keeps the first 4 and last 4 characters of long tokens and +// replaces the middle with asterisks; short tokens are fully masked so they +// cannot be reconstructed from the output. +func maskToken(t string) string { + if t == "" { + return "(unset)" + } + if len(t) <= 8 { + return "****" + } + return t[:4] + "..." + t[len(t)-4:] +} diff --git a/core/cli/cli.go b/core/cli/cli.go index 016d9a3..db73d1c 100644 --- a/core/cli/cli.go +++ b/core/cli/cli.go @@ -42,6 +42,9 @@ func New(name, shortDescription, defaultConfigPath string) *Cli { //disable help sub command c.Cmd.SetHelpCommand(&cobra.Command{Hidden: true}) c.Cmd.AddCommand(ConfigureCmd()) + c.Cmd.AddCommand(LoginCmd()) + c.Cmd.AddCommand(LogoutCmd()) + c.Cmd.AddCommand(WhoamiCmd()) c.Cmd.PersistentFlags().Bool("debug", false, "verbose logging") c.Cmd.PersistentFlags().String("config", defaultConfigPath, fmt.Sprintf("%s config file", c.Name)) c.Cmd.PersistentFlags().String("profile", "default", "profile name for config") diff --git a/core/client/grpc.go b/core/client/grpc.go index 11835df..6999a97 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -1,20 +1,103 @@ -// Package client handles the permify client to connect with the server +// Package client handles the permify client to connect with the server. +// +// The New function accepts an endpoint argument but also consults the local +// credential store (see core/credentials). Any value left blank in the +// argument falls back to the stored value, so callers can keep calling +// client.New(config.CliConfig.PermifyURL) unchanged while still benefiting +// from token authentication and mTLS configured via `permctl login`. package client import ( + "errors" + "fmt" + "os" + + "github.com/Permify/permify-cli/core/credentials" + "github.com/Permify/permify-cli/core/logger" permify "github.com/Permify/permify-go/v1" "google.golang.org/grpc" + grpccreds "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) -// New initializes a new permify client +// profileEnv lets the active profile be overridden without plumbing it +// through every call site. Falls back to credentials.DefaultProfile. +const profileEnv = "PERMIFY_PROFILE" + +// New initializes a new permify client. The endpoint argument takes +// precedence; if empty, the endpoint stored under the active profile is +// used. Stored token and mTLS material (if present) are always applied. +// +// New returns an error when: +// - no endpoint is supplied either by argument or by storage +// - stored mTLS certificate material is referenced but unreadable +// - the underlying grpc.Dial fails func New(endpoint string) (*permify.Client, error) { - client, err := permify.NewClient( - permify.Config{ - Endpoint: endpoint, - }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), + profile := os.Getenv(profileEnv) + stored, err := credentials.Load(profile) + if err != nil && !errors.Is(err, credentials.ErrNotFound) { + // A real error (insecure perms, corrupt YAML, ...) is surfaced. + // ErrNotFound is intentionally swallowed so the CLI keeps working + // without a credential file. + return nil, fmt.Errorf("load stored credentials: %w", err) + } + if stored == nil { + stored = &credentials.Credentials{} + } + + if endpoint == "" { + endpoint = stored.Endpoint + } + if endpoint == "" { + return nil, errors.New("permify endpoint is empty: pass one to client.New or run `permctl login --endpoint `") + } + + dialOpts, err := buildDialOptions(stored) + if err != nil { + return nil, err + } + + logger.Log.Debug("dialing permify", "endpoint", endpoint, "tls", stored.CertPath != "", "token", stored.Token != "") + + return permify.NewClient( + permify.Config{Endpoint: endpoint}, + dialOpts..., ) - return client, err +} + +// buildDialOptions translates stored credentials into grpc.DialOptions: +// +// - cert_path + cert_key => mTLS transport credentials +// - cert_path only => TLS with server cert as CA (rare but supported) +// - neither => insecure transport (preserves prior behavior) +// - token => per-RPC bearer token; secure flavor over TLS, +// non-secure flavor over plaintext +func buildDialOptions(c *credentials.Credentials) ([]grpc.DialOption, error) { + var opts []grpc.DialOption + + tlsConfigured := c.CertPath != "" || c.CertKey != "" + + if tlsConfigured { + if c.CertPath == "" || c.CertKey == "" { + return nil, errors.New("incomplete TLS credentials: both cert_path and cert_key must be set") + } + creds, err := grpccreds.NewClientTLSFromFile(c.CertPath, "") + if err != nil { + return nil, fmt.Errorf("load TLS credentials from %s: %w", c.CertPath, err) + } + opts = append(opts, grpc.WithTransportCredentials(creds)) + } else { + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + if c.Token != "" { + md := map[string]string{"authorization": "Bearer " + c.Token} + if tlsConfigured { + opts = append(opts, grpc.WithPerRPCCredentials(secureTokenCredentials(md))) + } else { + opts = append(opts, grpc.WithPerRPCCredentials(nonSecureTokenCredentials(md))) + } + } + + return opts, nil } diff --git a/core/credentials/credentials.go b/core/credentials/credentials.go new file mode 100644 index 0000000..56284ea --- /dev/null +++ b/core/credentials/credentials.go @@ -0,0 +1,285 @@ +// Package credentials handles secure storage and retrieval of permctl +// credentials (endpoint, token, mTLS cert/key paths). Credentials are +// persisted as YAML on the local filesystem with 0600 permissions so that +// only the owning user can read/write them. +// +// Storage layout: +// +// $PERMIFY_CREDENTIALS_FILE (when set, takes precedence) +// $HOME/.permctl-credentials.yaml (default) +// +// The file groups credentials under the active profile name, mirroring the +// existing config layout in core/config: +// +// default: +// endpoint: localhost:3478 +// token: secret-bearer-token +// cert_path: /etc/permify/client.crt +// cert_key: /etc/permify/client.key +// staging: +// endpoint: staging.permify.example:443 +// ... +package credentials + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + + "gopkg.in/yaml.v3" +) + +// DefaultProfile is the profile used when callers do not specify one. +const DefaultProfile = "default" + +// defaultFileName is the filename used under $HOME when no override is set. +const defaultFileName = ".permctl-credentials.yaml" + +// envFileOverride lets users (and tests) point at a non-default file. +const envFileOverride = "PERMIFY_CREDENTIALS_FILE" + +// secureFileMode is the permission mode for the credentials file. 0600 means +// the owning user is the only one able to read or write the file. +const secureFileMode fs.FileMode = 0o600 + +// secureDirMode is the permission mode used when creating the credentials +// directory. +const secureDirMode fs.FileMode = 0o700 + +// ErrNotFound is returned when no credentials are stored at the requested +// location, or when no entry exists for the requested profile. Callers can +// use errors.Is to detect this case and fall back to flags / interactive +// prompts. +var ErrNotFound = errors.New("credentials not found") + +// Credentials holds the connection material required to talk to a Permify +// server. All fields are optional individually; consumers decide which ones +// they need (for example, a plaintext endpoint needs no cert paths). +type Credentials struct { + Endpoint string `yaml:"endpoint,omitempty"` + Token string `yaml:"token,omitempty"` + CertPath string `yaml:"cert_path,omitempty"` + CertKey string `yaml:"cert_key,omitempty"` +} + +// IsZero reports whether c carries no information at all. Useful for +// distinguishing "stored but empty" from a real value. +func (c Credentials) IsZero() bool { + return c.Endpoint == "" && c.Token == "" && c.CertPath == "" && c.CertKey == "" +} + +// store is the on-disk representation: a map of profile name -> credentials. +type store map[string]Credentials + +// GetCredentialsPath returns the absolute path to the credentials file. +// The lookup order is: +// +// 1. $PERMIFY_CREDENTIALS_FILE if set +// 2. $HOME/.permctl-credentials.yaml otherwise +// +// On Windows, where $HOME is not always set, os.UserHomeDir is used as a +// fallback so the function works out of the box on all supported platforms. +func GetCredentialsPath() (string, error) { + if override := os.Getenv(envFileOverride); override != "" { + return override, nil + } + home, err := userHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, defaultFileName), nil +} + +// userHomeDir resolves the user's home directory, preferring $HOME for +// parity with the existing config package and falling back to +// os.UserHomeDir when unset (the typical Windows case). +func userHomeDir() (string, error) { + if home := os.Getenv("HOME"); home != "" { + return home, nil + } + return os.UserHomeDir() +} + +// Load reads credentials for the given profile from the default file. If +// profile is empty, DefaultProfile is used. Returns ErrNotFound when the +// file does not exist or the profile has no entry. +func Load(profile string) (*Credentials, error) { + path, err := GetCredentialsPath() + if err != nil { + return nil, err + } + return LoadFrom(path, profile) +} + +// LoadFrom reads credentials from an explicit path. Exposed primarily for +// testing but also useful for callers that want a non-default location +// without touching the environment. +func LoadFrom(path, profile string) (*Credentials, error) { + if profile == "" { + profile = DefaultProfile + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: %s", ErrNotFound, path) + } + return nil, fmt.Errorf("read credentials file: %w", err) + } + // Best-effort permission audit: warn (via error) on Unix if the file is + // world/group readable. We do not auto-fix to avoid surprising the user. + if err := assertSecurePermissions(path); err != nil { + return nil, err + } + s := store{} + if len(data) > 0 { + if err := yaml.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parse credentials file %s: %w", path, err) + } + } + creds, ok := s[profile] + if !ok { + return nil, fmt.Errorf("%w: profile %q in %s", ErrNotFound, profile, path) + } + return &creds, nil +} + +// Save writes credentials for the given profile to the default file, +// preserving entries for other profiles. The file is created with 0600 +// permissions; existing files have their permissions tightened to 0600 if +// they were previously broader. +func Save(profile string, creds *Credentials) error { + path, err := GetCredentialsPath() + if err != nil { + return err + } + return SaveTo(path, profile, creds) +} + +// SaveTo writes credentials to an explicit path. See Save for behavior. +func SaveTo(path, profile string, creds *Credentials) error { + if creds == nil { + return errors.New("credentials must not be nil") + } + if profile == "" { + profile = DefaultProfile + } + + // Load the existing file (if any) so other profiles are preserved. + s := store{} + if data, err := os.ReadFile(path); err == nil { + if len(data) > 0 { + if err := yaml.Unmarshal(data, &s); err != nil { + return fmt.Errorf("parse existing credentials file %s: %w", path, err) + } + } + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read credentials file: %w", err) + } + + s[profile] = *creds + return writeStore(path, s) +} + +// Delete removes the entry for the given profile. If no other profiles +// remain, the file itself is removed. Returns nil if the file or profile +// already does not exist. +func Delete(profile string) error { + path, err := GetCredentialsPath() + if err != nil { + return err + } + return DeleteFrom(path, profile) +} + +// DeleteFrom removes the entry for the given profile from an explicit path. +func DeleteFrom(path, profile string) error { + if profile == "" { + profile = DefaultProfile + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("read credentials file: %w", err) + } + s := store{} + if len(data) > 0 { + if err := yaml.Unmarshal(data, &s); err != nil { + return fmt.Errorf("parse credentials file %s: %w", path, err) + } + } + if _, ok := s[profile]; !ok { + return nil + } + delete(s, profile) + if len(s) == 0 { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove empty credentials file: %w", err) + } + return nil + } + return writeStore(path, s) +} + +// writeStore serializes the entire credential map to disk atomically with +// 0600 permissions. Used by SaveTo and DeleteFrom. +func writeStore(path string, s store) error { + out, err := yaml.Marshal(s) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, secureDirMode); err != nil { + return fmt.Errorf("create credentials directory: %w", err) + } + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".permctl-credentials-*.tmp") + if err != nil { + return fmt.Errorf("create temp credentials file: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(out); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp credentials file: %w", err) + } + if err := tmp.Chmod(secureFileMode); err != nil && runtime.GOOS != "windows" { + _ = tmp.Close() + return fmt.Errorf("chmod temp credentials file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp credentials file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("install credentials file: %w", err) + } + if err := os.Chmod(path, secureFileMode); err != nil && runtime.GOOS != "windows" { + return fmt.Errorf("chmod credentials file: %w", err) + } + return nil +} + +// assertSecurePermissions returns a descriptive error when the credentials +// file is more permissive than 0600 on POSIX systems. Windows ACL semantics +// don't map cleanly to mode bits, so the check is skipped there. +func assertSecurePermissions(path string) error { + if runtime.GOOS == "windows" { + return nil + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat credentials file: %w", err) + } + // Reject any bits beyond owner read/write. + if mode := info.Mode().Perm(); mode&0o077 != 0 { + return fmt.Errorf( + "insecure permissions on %s: have %#o, expected %#o (run: chmod 600 %s)", + path, mode, secureFileMode, path, + ) + } + return nil +} diff --git a/core/credentials/credentials_test.go b/core/credentials/credentials_test.go new file mode 100644 index 0000000..8055eaf --- /dev/null +++ b/core/credentials/credentials_test.go @@ -0,0 +1,218 @@ +package credentials + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +func tempCredsFile(t *testing.T) string { + t.Helper() + dir := t.TempDir() + return filepath.Join(dir, "credentials.yaml") +} + +func TestLoadFrom_MissingFile_ReturnsNotFound(t *testing.T) { + path := tempCredsFile(t) + _, err := LoadFrom(path, DefaultProfile) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestLoadFrom_MissingProfile_ReturnsNotFound(t *testing.T) { + path := tempCredsFile(t) + if err := SaveTo(path, "staging", &Credentials{Endpoint: "x"}); err != nil { + t.Fatalf("SaveTo: %v", err) + } + _, err := LoadFrom(path, "production") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound for missing profile, got %v", err) + } +} + +func TestSaveTo_LoadFrom_RoundTrip(t *testing.T) { + path := tempCredsFile(t) + want := &Credentials{ + Endpoint: "permify.example.com:443", + Token: "super-secret-token", + CertPath: "/etc/permify/client.crt", + CertKey: "/etc/permify/client.key", + } + if err := SaveTo(path, DefaultProfile, want); err != nil { + t.Fatalf("SaveTo: %v", err) + } + got, err := LoadFrom(path, DefaultProfile) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if *got != *want { + t.Fatalf("round-trip mismatch:\n want=%+v\n got =%+v", want, got) + } +} + +func TestSaveTo_PreservesOtherProfiles(t *testing.T) { + path := tempCredsFile(t) + stg := &Credentials{Endpoint: "stg:443", Token: "stg-tok"} + prd := &Credentials{Endpoint: "prd:443", Token: "prd-tok"} + if err := SaveTo(path, "staging", stg); err != nil { + t.Fatalf("SaveTo staging: %v", err) + } + if err := SaveTo(path, "production", prd); err != nil { + t.Fatalf("SaveTo production: %v", err) + } + // Staging should still be present after the second write. + got, err := LoadFrom(path, "staging") + if err != nil { + t.Fatalf("LoadFrom staging: %v", err) + } + if *got != *stg { + t.Fatalf("staging clobbered: want %+v got %+v", stg, got) + } + got, err = LoadFrom(path, "production") + if err != nil { + t.Fatalf("LoadFrom production: %v", err) + } + if *got != *prd { + t.Fatalf("production mismatch: want %+v got %+v", prd, got) + } +} + +func TestSaveTo_FilePermissionsAre0600(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics do not apply on Windows") + } + path := tempCredsFile(t) + if err := SaveTo(path, DefaultProfile, &Credentials{Token: "t"}); err != nil { + t.Fatalf("SaveTo: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("expected file mode 0600, got %#o", mode) + } +} + +func TestLoadFrom_InsecurePermissions_ReturnsError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics do not apply on Windows") + } + path := tempCredsFile(t) + if err := SaveTo(path, DefaultProfile, &Credentials{Token: "t"}); err != nil { + t.Fatalf("SaveTo: %v", err) + } + // Deliberately loosen permissions and check that Load rejects it. + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("chmod: %v", err) + } + if _, err := LoadFrom(path, DefaultProfile); err == nil { + t.Fatalf("expected insecure-permissions error, got nil") + } +} + +func TestSaveTo_NilCredentials_ReturnsError(t *testing.T) { + path := tempCredsFile(t) + if err := SaveTo(path, DefaultProfile, nil); err == nil { + t.Fatalf("expected error saving nil credentials, got nil") + } +} + +func TestDeleteFrom_RemovesProfile(t *testing.T) { + path := tempCredsFile(t) + if err := SaveTo(path, "staging", &Credentials{Token: "s"}); err != nil { + t.Fatalf("SaveTo staging: %v", err) + } + if err := SaveTo(path, "production", &Credentials{Token: "p"}); err != nil { + t.Fatalf("SaveTo production: %v", err) + } + if err := DeleteFrom(path, "staging"); err != nil { + t.Fatalf("DeleteFrom: %v", err) + } + if _, err := LoadFrom(path, "staging"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected staging gone, got err=%v", err) + } + // Production survives. + got, err := LoadFrom(path, "production") + if err != nil { + t.Fatalf("LoadFrom production after delete: %v", err) + } + if got.Token != "p" { + t.Fatalf("production token clobbered: got %q", got.Token) + } +} + +func TestDeleteFrom_LastProfileRemovesFile(t *testing.T) { + path := tempCredsFile(t) + if err := SaveTo(path, DefaultProfile, &Credentials{Token: "t"}); err != nil { + t.Fatalf("SaveTo: %v", err) + } + if err := DeleteFrom(path, DefaultProfile); err != nil { + t.Fatalf("DeleteFrom: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected credentials file removed, stat err=%v", err) + } +} + +func TestDeleteFrom_MissingFileIsNoOp(t *testing.T) { + path := tempCredsFile(t) + if err := DeleteFrom(path, DefaultProfile); err != nil { + t.Fatalf("expected nil error deleting from missing file, got %v", err) + } +} + +func TestGetCredentialsPath_HonorsEnvOverride(t *testing.T) { + want := filepath.Join(t.TempDir(), "custom.yaml") + t.Setenv(envFileOverride, want) + got, err := GetCredentialsPath() + if err != nil { + t.Fatalf("GetCredentialsPath: %v", err) + } + if got != want { + t.Fatalf("env override ignored: want %q got %q", want, got) + } +} + +func TestGetCredentialsPath_DefaultsToHome(t *testing.T) { + home := t.TempDir() + t.Setenv(envFileOverride, "") + t.Setenv("HOME", home) + // On Windows, os.UserHomeDir uses USERPROFILE; pin it to home so the + // fallback also resolves deterministically for this test. + t.Setenv("USERPROFILE", home) + got, err := GetCredentialsPath() + if err != nil { + t.Fatalf("GetCredentialsPath: %v", err) + } + want := filepath.Join(home, defaultFileName) + if got != want { + t.Fatalf("default path mismatch: want %q got %q", want, got) + } +} + +func TestCredentials_IsZero(t *testing.T) { + if !(Credentials{}).IsZero() { + t.Fatalf("empty Credentials should be zero") + } + if (Credentials{Endpoint: "x"}).IsZero() { + t.Fatalf("non-empty Credentials should not be zero") + } +} + +func TestLoadFrom_DefaultProfileWhenEmpty(t *testing.T) { + path := tempCredsFile(t) + if err := SaveTo(path, "", &Credentials{Endpoint: "e"}); err != nil { + t.Fatalf("SaveTo with empty profile: %v", err) + } + got, err := LoadFrom(path, "") + if err != nil { + t.Fatalf("LoadFrom with empty profile: %v", err) + } + if got.Endpoint != "e" { + t.Fatalf("default-profile round-trip mismatch: got %+v", got) + } +}