diff --git a/go.mod b/go.mod index c0feeae..05cb3e1 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,8 @@ module github.com/open-cli-collective/slack-chat-api go 1.26 require ( - github.com/open-cli-collective/cli-common v0.3.2 + github.com/byteness/keyring v1.11.0 + github.com/open-cli-collective/cli-common v0.4.1-0.20260620115552-351effa2004b github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.11.1 golang.org/x/term v0.44.0 @@ -15,7 +16,6 @@ require ( github.com/1password/onepassword-sdk-go v0.4.1-beta.1 // indirect github.com/byteness/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/byteness/go-libsecret v0.0.0-20260108215642-107379d3dee0 // indirect - github.com/byteness/keyring v1.11.0 // indirect github.com/byteness/percent v0.2.2 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 07439f4..7cdd7c8 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/noamcohen97/touchid-go v0.3.0 h1:fcXxVCizysD7KHRR6hrURt3nyNIs5JBGSbOIidD/3wo= github.com/noamcohen97/touchid-go v0.3.0/go.mod h1:X9MRNIBGEmPqwpDm1G3fQOAQX7fwBlhzUbnkDTxuta0= -github.com/open-cli-collective/cli-common v0.3.2 h1:jj3swzbzmZE9xALMOzTpCQXPylXDxuxWoSTT96G0Q84= -github.com/open-cli-collective/cli-common v0.3.2/go.mod h1:3AzjCT0V8xgslHlGi1+rUkcV+Vf5wONGwISQkufLZLQ= +github.com/open-cli-collective/cli-common v0.4.1-0.20260620115552-351effa2004b h1:85ps+R5ae8xyipytCzmyQLMQVDm1xMh+dQrEKCv9Hto= +github.com/open-cli-collective/cli-common v0.4.1-0.20260620115552-351effa2004b/go.mod h1:3AzjCT0V8xgslHlGi1+rUkcV+Vf5wONGwISQkufLZLQ= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/keychain/keychain_metadata_darwin_test.go b/internal/keychain/keychain_metadata_darwin_test.go new file mode 100644 index 0000000..f5cbf4a --- /dev/null +++ b/internal/keychain/keychain_metadata_darwin_test.go @@ -0,0 +1,118 @@ +//go:build darwin && cgo + +package keychain + +import ( + "os" + "strconv" + "testing" + "time" + + "github.com/byteness/keyring" + "github.com/open-cli-collective/cli-common/credstore" + + "github.com/open-cli-collective/slack-chat-api/internal/config" + "github.com/open-cli-collective/slack-chat-api/internal/testutil" +) + +func TestKeychainMetadataGated(t *testing.T) { + if os.Getenv("SLCK_KEYCHAIN_METADATA_TEST") != "1" { + t.Skip("set SLCK_KEYCHAIN_METADATA_TEST=1 to write to the real macOS Keychain") + } + + home := os.Getenv("HOME") + testutil.Setup(t) + // testutil.Setup isolates HOME for config tests, but the real macOS + // Keychain backend uses HOME to find the user's default login keychain. + t.Setenv("HOME", home) + SetBackendFlagOverride(string(credstore.BackendKeychain), true) + t.Cleanup(func() { SetBackendFlagOverride("", false) }) + + profile := "metadata-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ref := "slack-chat-api/" + profile + t.Logf("using synthetic Keychain ref %q", ref) + + st, err := openWith(&config.Config{CredentialRef: ref}, false, false) + if err != nil { + t.Fatalf("openWith(%q): %v", ref, err) + } + t.Cleanup(func() { _ = st.Close() }) + t.Cleanup(func() { _ = st.DeleteBotToken() }) + t.Cleanup(func() { _ = st.DeleteUserToken() }) + + kr, err := keyring.Open(keyring.Config{ + ServiceName: "slack-chat-api", + AllowedBackends: []keyring.BackendType{keyring.KeychainBackend}, + KeychainTrustApplication: true, + }) + if err != nil { + t.Fatalf("open ByteNess Keychain backend: %v", err) + } + + cases := []struct { + key string + set func(string) error + }{ + {key: KeyBotToken, set: st.SetBotToken}, + {key: KeyUserToken, set: st.SetUserToken}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.key, func(t *testing.T) { + account := profile + "/" + tc.key + t.Logf("using synthetic Keychain account %q", account) + t.Cleanup(func() { _ = kr.Remove(account) }) + + wantLabel := "slack-chat-api " + account + wantDescription := "Credential for slack-chat-api " + account + + if err := tc.set("xox-test-metadata-fresh"); err != nil { + t.Fatalf("fresh set %s: %v", tc.key, err) + } + assertMetadata(t, kr, account, wantLabel, wantDescription) + + if err := kr.Remove(account); err != nil { + t.Fatalf("remove fresh item before stale seed: %v", err) + } + + if err := kr.Set(keyring.Item{Key: account, Data: []byte("legacy")}); err != nil { + t.Fatalf("seed stale metadata item: %v", err) + } + seeded, err := kr.GetMetadata(account) + if err != nil { + t.Fatalf("GetMetadata(%q) after seed: %v", account, err) + } + if seeded.Item == nil { + t.Fatalf("GetMetadata(%q) after seed returned nil item", account) + } + if seeded.Label == wantLabel && seeded.Description == wantDescription { + t.Fatalf("seeded item already has target metadata label=%q description=%q", seeded.Label, seeded.Description) + } + + if err := tc.set("xox-test-metadata"); err != nil { + t.Fatalf("set %s: %v", tc.key, err) + } + + assertMetadata(t, kr, account, wantLabel, wantDescription) + }) + } +} + +func assertMetadata(t *testing.T, kr keyring.Keyring, account, wantLabel, wantDescription string) { + t.Helper() + + md, err := kr.GetMetadata(account) + if err != nil { + t.Fatalf("GetMetadata(%q): %v", account, err) + } + if md.Item == nil { + t.Fatalf("GetMetadata(%q) returned nil item", account) + } + if md.Label != wantLabel { + t.Fatalf("metadata label = %q, want %q", md.Label, wantLabel) + } + if md.Description != wantDescription { + t.Fatalf("metadata description = %q, want %q", md.Description, wantDescription) + } +}