Compare commits
12 Commits
main
...
fix/keycha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf1f417a3 | ||
|
|
58d38ad057 | ||
|
|
1f88ef1956 | ||
|
|
4ffe49a398 | ||
|
|
11f62e7086 | ||
|
|
efd0a8077f | ||
|
|
c1f2fd5bfc | ||
|
|
f51536e042 | ||
|
|
fd652aadd7 | ||
|
|
c02aecf12c | ||
|
|
4dfbf9d706 | ||
|
|
a441ed4448 |
@ -7,6 +7,7 @@
|
||||
- Homebrew: tap now installs GitHub release binaries (macOS) to reduce Keychain prompt churn.
|
||||
- Secrets: add `GOG_KEYRING_BACKEND={auto|keychain|file}` to force backend (use `file` to avoid Keychain prompts; pair with `GOG_KEYRING_PASSWORD`).
|
||||
- Docs: explain macOS Keychain prompts and backend options.
|
||||
- Auth: proactive macOS keychain unlock checks for auth flows (#28) — thanks @salmonumbrella.
|
||||
|
||||
## 0.4.2 - 2025-12-31
|
||||
|
||||
|
||||
@ -19,10 +19,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
openSecretsStore = secrets.OpenDefault
|
||||
authorizeGoogle = googleauth.Authorize
|
||||
startManageServer = googleauth.StartManageServer
|
||||
checkRefreshToken = googleauth.CheckRefreshToken
|
||||
openSecretsStore = secrets.OpenDefault
|
||||
authorizeGoogle = googleauth.Authorize
|
||||
startManageServer = googleauth.StartManageServer
|
||||
checkRefreshToken = googleauth.CheckRefreshToken
|
||||
ensureKeychainAccess = secrets.EnsureKeychainAccess
|
||||
)
|
||||
|
||||
type AuthCmd struct {
|
||||
@ -223,7 +224,7 @@ type AuthTokensImportCmd struct {
|
||||
InPath string `arg:"" name:"inPath" help:"Input path or '-' for stdin"`
|
||||
}
|
||||
|
||||
func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
|
||||
func (c *AuthTokensImportCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
inPath := c.InPath
|
||||
var b []byte
|
||||
@ -264,6 +265,11 @@ func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
|
||||
createdAt = parsed
|
||||
}
|
||||
|
||||
// Pre-flight: ensure keychain is accessible before storing token
|
||||
if keychainErr := ensureKeychainAccess(flags != nil && flags.NoInput); keychainErr != nil {
|
||||
return fmt.Errorf("keychain access: %w", keychainErr)
|
||||
}
|
||||
|
||||
store, err := openSecretsStore()
|
||||
if err != nil {
|
||||
return err
|
||||
@ -297,7 +303,7 @@ type AuthAddCmd struct {
|
||||
ServicesCSV string `name:"services" help:"Services to authorize: all or comma-separated gmail,calendar,drive,contacts,tasks,sheets,people" default:"all"`
|
||||
}
|
||||
|
||||
func (c *AuthAddCmd) Run(ctx context.Context) error {
|
||||
func (c *AuthAddCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
|
||||
var services []googleauth.Service
|
||||
@ -327,6 +333,11 @@ func (c *AuthAddCmd) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pre-flight: ensure keychain is accessible before starting OAuth
|
||||
if keychainErr := ensureKeychainAccess(flags != nil && flags.NoInput); keychainErr != nil {
|
||||
return fmt.Errorf("keychain access: %w", keychainErr)
|
||||
}
|
||||
|
||||
refreshToken, err := authorizeGoogle(ctx, googleauth.AuthorizeOptions{
|
||||
Services: services,
|
||||
Scopes: scopes,
|
||||
@ -478,7 +489,7 @@ type AuthManageCmd struct {
|
||||
Timeout time.Duration `name:"timeout" help:"Server timeout duration" default:"10m"`
|
||||
}
|
||||
|
||||
func (c *AuthManageCmd) Run(ctx context.Context) error {
|
||||
func (c *AuthManageCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
var services []googleauth.Service
|
||||
if strings.EqualFold(strings.TrimSpace(c.ServicesCSV), "") || strings.EqualFold(strings.TrimSpace(c.ServicesCSV), "all") {
|
||||
services = googleauth.AllServices()
|
||||
@ -498,9 +509,14 @@ func (c *AuthManageCmd) Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if keychainErr := ensureKeychainAccess(flags != nil && flags.NoInput); keychainErr != nil {
|
||||
return fmt.Errorf("keychain access: %w", keychainErr)
|
||||
}
|
||||
|
||||
return startManageServer(ctx, googleauth.ManageServerOptions{
|
||||
Timeout: c.Timeout,
|
||||
Services: services,
|
||||
ForceConsent: c.ForceConsent,
|
||||
NoInput: flags != nil && flags.NoInput,
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@ -13,11 +14,15 @@ import (
|
||||
func TestAuthAddCmd_JSON(t *testing.T) {
|
||||
origAuth := authorizeGoogle
|
||||
origOpen := openSecretsStore
|
||||
origKeychain := ensureKeychainAccess
|
||||
t.Cleanup(func() {
|
||||
authorizeGoogle = origAuth
|
||||
openSecretsStore = origOpen
|
||||
ensureKeychainAccess = origKeychain
|
||||
})
|
||||
|
||||
ensureKeychainAccess = func(bool) error { return nil }
|
||||
|
||||
store := newMemSecretsStore()
|
||||
openSecretsStore = func() (secrets.Store, error) { return store, nil }
|
||||
|
||||
@ -70,3 +75,46 @@ func TestAuthAddCmd_JSON(t *testing.T) {
|
||||
t.Fatalf("unexpected token: %#v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAddCmd_KeychainError(t *testing.T) {
|
||||
origAuth := authorizeGoogle
|
||||
origOpen := openSecretsStore
|
||||
origKeychain := ensureKeychainAccess
|
||||
t.Cleanup(func() {
|
||||
authorizeGoogle = origAuth
|
||||
openSecretsStore = origOpen
|
||||
ensureKeychainAccess = origKeychain
|
||||
})
|
||||
|
||||
// Simulate keychain locked error
|
||||
var gotNoInput bool
|
||||
ensureKeychainAccess = func(noInput bool) error {
|
||||
gotNoInput = noInput
|
||||
return errors.New("keychain is locked")
|
||||
}
|
||||
|
||||
authCalled := false
|
||||
authorizeGoogle = func(_ context.Context, _ googleauth.AuthorizeOptions) (string, error) {
|
||||
authCalled = true
|
||||
return "rt", nil
|
||||
}
|
||||
|
||||
store := newMemSecretsStore()
|
||||
openSecretsStore = func() (secrets.Store, error) { return store, nil }
|
||||
|
||||
cmd := &AuthAddCmd{Email: "test@example.com", ServicesCSV: "gmail"}
|
||||
err := cmd.Run(context.Background(), &RootFlags{NoInput: true})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when keychain is locked")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "keychain") {
|
||||
t.Errorf("expected error to mention keychain, got: %v", err)
|
||||
}
|
||||
if authCalled {
|
||||
t.Error("authorizeGoogle should not be called when keychain check fails")
|
||||
}
|
||||
if !gotNoInput {
|
||||
t.Error("expected noInput to be forwarded to keychain access")
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,8 +91,13 @@ func (s *memSecretsStore) SetDefaultAccount(email string) error {
|
||||
|
||||
func TestAuthTokens_ExportImportRoundtrip_JSON(t *testing.T) {
|
||||
origOpen := openSecretsStore
|
||||
t.Cleanup(func() { openSecretsStore = origOpen })
|
||||
origKeychain := ensureKeychainAccess
|
||||
t.Cleanup(func() {
|
||||
openSecretsStore = origOpen
|
||||
ensureKeychainAccess = origKeychain
|
||||
})
|
||||
|
||||
ensureKeychainAccess = func(bool) error { return nil }
|
||||
store := newMemSecretsStore()
|
||||
createdAt := time.Date(2025, 12, 12, 0, 0, 0, 0, time.UTC)
|
||||
if err := store.SetToken("A@B.COM", secrets.Token{
|
||||
|
||||
18
internal/cmd/auth_keychain_test.go
Normal file
18
internal/cmd/auth_keychain_test.go
Normal file
@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/secrets"
|
||||
)
|
||||
|
||||
func TestAuthAddCmd_ChecksKeychainFirst(t *testing.T) {
|
||||
// Verify the EnsureKeychainAccess function exists and is callable
|
||||
err := secrets.EnsureKeychainAccess(true)
|
||||
if err != nil {
|
||||
// If this fails, keychain might be locked - that's expected in some test environments
|
||||
t.Skipf("Keychain appears to be locked, skipping: %v", err)
|
||||
}
|
||||
}
|
||||
@ -10,15 +10,18 @@ import (
|
||||
|
||||
func TestAuthManageCmd_ServicesAndOptions(t *testing.T) {
|
||||
orig := startManageServer
|
||||
origKeychain := ensureKeychainAccess
|
||||
t.Cleanup(func() { startManageServer = orig })
|
||||
t.Cleanup(func() { ensureKeychainAccess = origKeychain })
|
||||
|
||||
var got googleauth.ManageServerOptions
|
||||
startManageServer = func(ctx context.Context, opts googleauth.ManageServerOptions) error {
|
||||
got = opts
|
||||
return nil
|
||||
}
|
||||
ensureKeychainAccess = func(bool) error { return nil }
|
||||
|
||||
if err := runKong(t, &AuthManageCmd{}, []string{"--services", "gmail,drive,gmail", "--force-consent", "--timeout", "2m"}, context.Background(), nil); err != nil {
|
||||
if err := runKong(t, &AuthManageCmd{}, []string{"--services", "gmail,drive,gmail", "--force-consent", "--timeout", "2m"}, context.Background(), &RootFlags{NoInput: true}); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
@ -31,14 +34,20 @@ func TestAuthManageCmd_ServicesAndOptions(t *testing.T) {
|
||||
if len(got.Services) != 2 {
|
||||
t.Fatalf("expected de-duped services, got %#v", got.Services)
|
||||
}
|
||||
if !got.NoInput {
|
||||
t.Fatalf("expected no-input forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthManageCmd_InvalidService(t *testing.T) {
|
||||
orig := startManageServer
|
||||
origKeychain := ensureKeychainAccess
|
||||
t.Cleanup(func() { startManageServer = orig })
|
||||
t.Cleanup(func() { ensureKeychainAccess = origKeychain })
|
||||
startManageServer = func(context.Context, googleauth.ManageServerOptions) error { return nil }
|
||||
ensureKeychainAccess = func(bool) error { return nil }
|
||||
|
||||
if err := runKong(t, &AuthManageCmd{}, []string{"--services", "nope"}, context.Background(), nil); err == nil {
|
||||
if err := runKong(t, &AuthManageCmd{}, []string{"--services", "nope"}, context.Background(), &RootFlags{}); err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,8 +144,13 @@ func TestAuthList_Check_Text(t *testing.T) {
|
||||
|
||||
func TestAuthTokensExportImport_Text(t *testing.T) {
|
||||
origOpen := openSecretsStore
|
||||
t.Cleanup(func() { openSecretsStore = origOpen })
|
||||
origKeychain := ensureKeychainAccess
|
||||
t.Cleanup(func() {
|
||||
openSecretsStore = origOpen
|
||||
ensureKeychainAccess = origKeychain
|
||||
})
|
||||
|
||||
ensureKeychainAccess = func(bool) error { return nil }
|
||||
store := newMemSecretsStore()
|
||||
openSecretsStore = func() (secrets.Store, error) { return store, nil }
|
||||
|
||||
|
||||
@ -12,11 +12,15 @@ import (
|
||||
func TestExecute_AuthAdd_JSON(t *testing.T) {
|
||||
origOpen := openSecretsStore
|
||||
origAuth := authorizeGoogle
|
||||
origKeychain := ensureKeychainAccess
|
||||
t.Cleanup(func() {
|
||||
openSecretsStore = origOpen
|
||||
authorizeGoogle = origAuth
|
||||
ensureKeychainAccess = origKeychain
|
||||
})
|
||||
|
||||
ensureKeychainAccess = func(bool) error { return nil }
|
||||
|
||||
store := newMemSecretsStore()
|
||||
openSecretsStore = func() (secrets.Store, error) { return store, nil }
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ type ManageServerOptions struct {
|
||||
Timeout time.Duration
|
||||
Services []Service
|
||||
ForceConsent bool
|
||||
NoInput bool
|
||||
}
|
||||
|
||||
// ManageServer handles the accounts management UI
|
||||
@ -293,6 +294,14 @@ func (ms *ManageServer) handleOAuthCallback(w http.ResponseWriter, r *http.Reque
|
||||
email = "user@gmail.com"
|
||||
}
|
||||
|
||||
// Pre-flight: ensure keychain is accessible before storing token
|
||||
if err := secrets.EnsureKeychainAccess(ms.opts.NoInput); err != nil { //nolint:contextcheck,nolintlint // keychain ops don't use context; nolint unused on non-Darwin
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
renderErrorPage(w, "Keychain is locked: "+err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Store the token
|
||||
serviceNames := make([]string, 0, len(services))
|
||||
for _, svc := range services {
|
||||
|
||||
106
internal/secrets/keychain_darwin.go
Normal file
106
internal/secrets/keychain_darwin.go
Normal file
@ -0,0 +1,106 @@
|
||||
//go:build darwin
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
// errSecInteractionNotAllowed is macOS Security framework error -25308
|
||||
errSecInteractionNotAllowed = "-25308"
|
||||
)
|
||||
|
||||
var (
|
||||
errKeychainPathUnknown = errors.New("cannot determine login keychain path")
|
||||
errKeychainNoTTY = errors.New("keychain is locked and no TTY available for password prompt")
|
||||
errKeychainNoInput = errors.New("keychain is locked and prompting is disabled")
|
||||
errKeychainUnlock = errors.New("unlock keychain: incorrect password or keychain error")
|
||||
)
|
||||
|
||||
// IsKeychainLockedError returns true if the error string indicates a locked keychain.
|
||||
func IsKeychainLockedError(errStr string) bool {
|
||||
return strings.Contains(errStr, errSecInteractionNotAllowed)
|
||||
}
|
||||
|
||||
// loginKeychainPath returns the path to the user's login keychain.
|
||||
func loginKeychainPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Join(home, "Library", "Keychains", "login.keychain-db")
|
||||
}
|
||||
|
||||
// CheckKeychainLocked checks if the login keychain is locked.
|
||||
// Returns true if locked, false if unlocked or on error detecting status.
|
||||
func CheckKeychainLocked() bool {
|
||||
path := loginKeychainPath()
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(context.Background(), "security", "show-keychain-info", path) //nolint:gosec // path is from os.UserHomeDir, not user input
|
||||
err := cmd.Run()
|
||||
// Exit code 0 = unlocked, non-zero = locked or error
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// UnlockKeychain prompts for password and unlocks the login keychain.
|
||||
// Returns nil on success, error on failure.
|
||||
func UnlockKeychain() error {
|
||||
path := loginKeychainPath()
|
||||
if path == "" {
|
||||
return errKeychainPathUnknown
|
||||
}
|
||||
|
||||
// Check if we have a TTY for password input
|
||||
if !term.IsTerminal(int(syscall.Stdin)) {
|
||||
return fmt.Errorf("%w\n\nTo unlock manually, run:\n security unlock-keychain ~/Library/Keychains/login.keychain-db", errKeychainNoTTY)
|
||||
}
|
||||
|
||||
fmt.Fprint(os.Stderr, "Keychain is locked. Enter your macOS login password to unlock: ")
|
||||
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
|
||||
fmt.Fprintln(os.Stderr) // newline after password input
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
|
||||
// Pass password via stdin to avoid exposing it in process list (ps aux)
|
||||
cmd := exec.CommandContext(context.Background(), "security", "unlock-keychain", path) //nolint:gosec // path is from os.UserHomeDir
|
||||
cmd.Stdin = strings.NewReader(string(password) + "\n")
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errKeychainUnlock
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureKeychainAccess checks if the keychain is accessible and unlocks it if needed.
|
||||
// Returns nil if keychain is accessible (unlocked or successfully unlocked).
|
||||
// Returns error if keychain cannot be unlocked.
|
||||
func EnsureKeychainAccess(noInput bool) error {
|
||||
if !CheckKeychainLocked() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if noInput {
|
||||
return fmt.Errorf("%w\n\nTo unlock manually, run:\n security unlock-keychain ~/Library/Keychains/login.keychain-db", errKeychainNoInput)
|
||||
}
|
||||
|
||||
return UnlockKeychain()
|
||||
}
|
||||
49
internal/secrets/keychain_darwin_test.go
Normal file
49
internal/secrets/keychain_darwin_test.go
Normal file
@ -0,0 +1,49 @@
|
||||
//go:build darwin
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsKeychainLockedError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err string
|
||||
expected bool
|
||||
}{
|
||||
{"locked error", "store token: User Interaction is not allowed. (-25308)", true},
|
||||
{"just error code", "some error (-25308)", true},
|
||||
{"different error", "store token: some other error", false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsKeychainLockedError(tt.err)
|
||||
if got != tt.expected {
|
||||
t.Errorf("IsKeychainLockedError(%q) = %v, want %v", tt.err, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeychainPath(t *testing.T) {
|
||||
path := loginKeychainPath()
|
||||
if path == "" {
|
||||
t.Error("keychainPath() returned empty string")
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(path, "login.keychain-db") {
|
||||
t.Errorf("unexpected keychain path: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeychainAccess_UnlockedKeychain(t *testing.T) {
|
||||
// On a normal dev machine, keychain should be unlocked
|
||||
err := EnsureKeychainAccess(true)
|
||||
if err != nil {
|
||||
t.Skipf("Keychain appears to be locked, skipping: %v", err)
|
||||
}
|
||||
}
|
||||
23
internal/secrets/keychain_other.go
Normal file
23
internal/secrets/keychain_other.go
Normal file
@ -0,0 +1,23 @@
|
||||
//go:build !darwin
|
||||
|
||||
package secrets
|
||||
|
||||
// IsKeychainLockedError returns false on non-macOS platforms.
|
||||
func IsKeychainLockedError(_ string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckKeychainLocked returns false on non-macOS platforms.
|
||||
func CheckKeychainLocked() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// UnlockKeychain is a no-op on non-macOS platforms.
|
||||
func UnlockKeychain() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureKeychainAccess is a no-op on non-macOS platforms.
|
||||
func EnsureKeychainAccess(_ bool) error {
|
||||
return nil
|
||||
}
|
||||
@ -63,6 +63,19 @@ func allowedBackendsFromEnv() ([]keyring.BackendType, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// wrapKeychainError wraps keychain errors with helpful guidance on macOS.
|
||||
func wrapKeychainError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if IsKeychainLockedError(err.Error()) {
|
||||
return fmt.Errorf("%w\n\nYour macOS keychain is locked. To unlock it, run:\n security unlock-keychain ~/Library/Keychains/login.keychain-db", err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func fileKeyringPasswordFuncFrom(password string, isTTY bool) keyring.PromptFunc {
|
||||
if password != "" {
|
||||
return keyring.FixedStringPrompt(password)
|
||||
@ -153,7 +166,7 @@ func (s *KeyringStore) SetToken(email string, tok Token) error {
|
||||
Key: tokenKey(email),
|
||||
Data: payload,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("store token: %w", err)
|
||||
return wrapKeychainError(fmt.Errorf("store token: %w", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -98,3 +100,64 @@ func TestKeyringStore_DefaultAccount_Roundtrip(t *testing.T) {
|
||||
t.Fatalf("unexpected default: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errTestKeychainLocked = errors.New("store token: User Interaction is not allowed. (-25308)")
|
||||
errTestOther = errors.New("store token: some other error")
|
||||
)
|
||||
|
||||
func TestWrapKeychainError(t *testing.T) {
|
||||
if !IsKeychainLockedError("User Interaction is not allowed. (-25308)") {
|
||||
t.Skip("wrapKeychainError only wraps errors on macOS")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantWrapped bool
|
||||
wantContain string
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
wantWrapped: false,
|
||||
},
|
||||
{
|
||||
name: "keychain locked error",
|
||||
err: errTestKeychainLocked,
|
||||
wantWrapped: true,
|
||||
wantContain: "security unlock-keychain",
|
||||
},
|
||||
{
|
||||
name: "other error",
|
||||
err: errTestOther,
|
||||
wantWrapped: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wrapped := wrapKeychainError(tt.err)
|
||||
|
||||
if tt.err == nil {
|
||||
if wrapped != nil {
|
||||
t.Error("expected nil error to remain nil")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantWrapped {
|
||||
if errors.Is(wrapped, tt.err) && wrapped.Error() == tt.err.Error() {
|
||||
t.Error("expected error to be wrapped with additional context")
|
||||
}
|
||||
|
||||
if tt.wantContain != "" && !strings.Contains(wrapped.Error(), tt.wantContain) {
|
||||
t.Errorf("wrapped error %q should contain %q", wrapped.Error(), tt.wantContain)
|
||||
}
|
||||
} else if !errors.Is(wrapped, tt.err) {
|
||||
t.Error("expected error to remain unchanged")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user