From 49e1d6ba6f6b534b0c58707fa0c90031951af4cf Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 20 Jun 2026 08:37:13 -0400 Subject: [PATCH] test: cover google keychain metadata Closes #157 --- go.mod | 4 +- go.sum | 4 +- .../keychain/keychain_metadata_darwin_test.go | 134 ++++++++++++++++++ 3 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 internal/keychain/keychain_metadata_darwin_test.go diff --git a/go.mod b/go.mod index 1a5ab6a..e33d24b 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,11 @@ go 1.26 require ( github.com/atotto/clipboard v0.1.4 + github.com/byteness/keyring v1.11.0 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/muesli/termenv v0.16.0 - github.com/open-cli-collective/cli-common v0.3.2 + github.com/open-cli-collective/cli-common v0.4.1-0.20260620115552-351effa2004b github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.8.0 github.com/yuin/goldmark v1.8.2 @@ -27,7 +28,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.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/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index cf4122e..fdd385d 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 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/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= diff --git a/internal/keychain/keychain_metadata_darwin_test.go b/internal/keychain/keychain_metadata_darwin_test.go new file mode 100644 index 0000000..466e0fe --- /dev/null +++ b/internal/keychain/keychain_metadata_darwin_test.go @@ -0,0 +1,134 @@ +//go:build darwin && cgo + +package keychain + +import ( + "os" + "strconv" + "testing" + "time" + + "github.com/byteness/keyring" + "github.com/open-cli-collective/cli-common/credstore" + "golang.org/x/oauth2" + + "github.com/open-cli-collective/google-readonly/internal/config" + "github.com/open-cli-collective/google-readonly/internal/credtest" +) + +func TestKeychainMetadataGated(t *testing.T) { + if os.Getenv("GRO_KEYCHAIN_METADATA_TEST") != "1" { + t.Skip("set GRO_KEYCHAIN_METADATA_TEST=1 to write to the real macOS Keychain") + } + + home := os.Getenv("HOME") + credtest.Setup(t) + // credtest.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 := "google-readonly/" + profile + account := profile + "/" + KeyOAuthToken + t.Logf("using synthetic Keychain ref %q account %q", ref, account) + + 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.DeleteToken() }) + + kr, err := keyring.Open(keyring.Config{ + ServiceName: "google-readonly", + AllowedBackends: []keyring.BackendType{keyring.KeychainBackend}, + KeychainTrustApplication: true, + }) + if err != nil { + t.Fatalf("open ByteNess Keychain backend: %v", err) + } + t.Cleanup(func() { _ = kr.Remove(account) }) + + wantLabel := "google-readonly " + account + wantDescription := "Credential for google-readonly " + account + + if err := st.SetToken(&oauth2.Token{AccessToken: "fresh-access", RefreshToken: "fresh-refresh"}); err != nil { + t.Fatalf("fresh SetToken: %v", err) + } + assertToken(t, st, "fresh-access", "fresh-refresh") + assertMetadata(t, kr, account, wantLabel, wantDescription) + + if err := kr.Remove(account); err != nil { + t.Fatalf("remove fresh item before stale seed: %v", err) + } + + const ( + staleLabel = "stale google token" + staleDescription = "stale metadata before repair" + ) + if err := kr.Set(keyring.Item{ + Key: account, + Data: []byte(`{"access_token":"legacy","refresh_token":"legacy-refresh"}`), + Label: staleLabel, + Description: staleDescription, + }); 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 != staleLabel { + t.Fatalf("seeded label = %q, want %q", seeded.Label, staleLabel) + } + if seeded.Description != staleDescription { + t.Fatalf("seeded description = %q, want %q", seeded.Description, staleDescription) + } + + if err := st.SetToken(&oauth2.Token{AccessToken: "repair-access", RefreshToken: "repair-refresh"}); err != nil { + t.Fatalf("repair SetToken: %v", err) + } + assertToken(t, st, "repair-access", "repair-refresh") + assertMetadata(t, kr, account, wantLabel, wantDescription) +} + +func assertToken(t *testing.T, st *Store, wantAccess, wantRefresh string) { + t.Helper() + + tok, err := st.Token() + if err != nil { + t.Fatalf("Token: %v", err) + } + if tok.AccessToken != wantAccess { + t.Fatalf("AccessToken = %q, want %q", tok.AccessToken, wantAccess) + } + if tok.RefreshToken != wantRefresh { + t.Fatalf("RefreshToken = %q, want %q", tok.RefreshToken, wantRefresh) + } +} + +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 len(md.Data) != 0 { + t.Fatalf("metadata Data length = %d, want 0", len(md.Data)) + } + 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) + } +}