Skip to content

Commit 3b397a8

Browse files
fix(application): regenerate CLI-managed key on select when UUID missing
A migrated application can have a usable key in the keychain but no api_key_uuid in state.toml, so 'apikeys rotate' has nothing to target. 'application select' reused the existing key and never generated a UUID. Now regenerate a fresh CLI-managed key whenever none is on record. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 98849de commit 3b397a8

2 files changed

Lines changed: 130 additions & 2 deletions

File tree

pkg/cmd/application/selectapp/select.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,12 @@ func runSelectCmd(opts *SelectOptions) (*dashboard.Application, error) {
121121
}
122122

123123
// Reuse a key already stored for this application (keychain, then legacy
124-
// config.toml) before creating a new one on the dashboard.
125-
if !apputil.ReuseExistingAPIKey(opts.Config, chosen) {
124+
// config.toml) before creating a new one on the dashboard. A migrated
125+
// application may have a usable key but no stored UUID (legacy config.toml
126+
// never recorded one); without a UUID, commands like `apikeys rotate` can't
127+
// target it, so regenerate a fresh CLI-managed key whenever none is on record.
128+
_, hasUUID := opts.Config.APIKeyUUID(chosen.ID)
129+
if !hasUUID || !apputil.ReuseExistingAPIKey(opts.Config, chosen) {
126130
if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, chosen); err != nil {
127131
return nil, err
128132
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package selectapp
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"github.com/zalando/go-keyring"
13+
14+
"github.com/algolia/cli/api/dashboard"
15+
"github.com/algolia/cli/pkg/auth"
16+
"github.com/algolia/cli/pkg/iostreams"
17+
"github.com/algolia/cli/pkg/keychain"
18+
"github.com/algolia/cli/test"
19+
)
20+
21+
func seedToken(t *testing.T) {
22+
t.Helper()
23+
keyring.MockInit()
24+
require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{
25+
AccessToken: "test-token",
26+
ExpiresIn: 3600,
27+
CreatedAt: time.Now().Unix(),
28+
}))
29+
}
30+
31+
// selectServer stubs the dashboard endpoints select uses: listing applications
32+
// and creating an API key. createHit records whether the key-creation endpoint
33+
// was called.
34+
func selectServer(t *testing.T, createHit *bool) *httptest.Server {
35+
t.Helper()
36+
mux := http.NewServeMux()
37+
mux.HandleFunc("/1/applications", func(w http.ResponseWriter, _ *http.Request) {
38+
require.NoError(t, json.NewEncoder(w).Encode(dashboard.ApplicationsResponse{
39+
Data: []dashboard.ApplicationResource{{
40+
ID: "APP1",
41+
Type: "application",
42+
Attributes: dashboard.ApplicationAttributes{
43+
ApplicationID: "APP1",
44+
Name: "My App",
45+
},
46+
}},
47+
Meta: dashboard.PaginationMeta{CurrentPage: 1, TotalPages: 1},
48+
}))
49+
})
50+
mux.HandleFunc(
51+
"/1/applications/APP1/api-keys",
52+
func(w http.ResponseWriter, _ *http.Request) {
53+
*createHit = true
54+
w.WriteHeader(http.StatusCreated)
55+
require.NoError(t, json.NewEncoder(w).Encode(dashboard.CreateAPIKeyResponse{
56+
Data: dashboard.APIKeyResource{
57+
ID: "new-uuid",
58+
Attributes: dashboard.APIKeyAttributes{Value: "new-key"},
59+
},
60+
}))
61+
},
62+
)
63+
return httptest.NewServer(mux)
64+
}
65+
66+
func newSelectOpts(t *testing.T, srv *httptest.Server, cfg *test.ConfigStub) *SelectOptions {
67+
t.Helper()
68+
seedToken(t)
69+
io, _, _, _ := iostreams.Test()
70+
return &SelectOptions{
71+
IO: io,
72+
Config: cfg,
73+
AppName: "My App", // bypasses the interactive picker
74+
NewDashboardClient: func(string) *dashboard.Client {
75+
c := dashboard.NewClientWithHTTPClient("test", srv.Client())
76+
c.APIURL = srv.URL
77+
return c
78+
},
79+
}
80+
}
81+
82+
func Test_runSelectCmd_RegeneratesKeyWhenNoUUID(t *testing.T) {
83+
createHit := false
84+
srv := selectServer(t, &createHit)
85+
defer srv.Close()
86+
87+
// Migrated application: present in state with an alias, but no UUID.
88+
cfg := &test.ConfigStub{
89+
SavedApps: map[string]test.SavedApplication{
90+
"APP1": {Alias: "my app", APIKey: "old-key"},
91+
},
92+
}
93+
opts := newSelectOpts(t, srv, cfg)
94+
95+
app, err := runSelectCmd(opts)
96+
require.NoError(t, err)
97+
require.NotNil(t, app)
98+
99+
assert.True(t, createHit, "expected a fresh key to be generated when no UUID is stored")
100+
assert.Equal(t, "new-uuid", cfg.SavedApps["APP1"].APIKeyUUID)
101+
assert.Equal(t, "new-key", cfg.SavedApps["APP1"].APIKey)
102+
}
103+
104+
func Test_runSelectCmd_ReusesKeyWhenUUIDPresent(t *testing.T) {
105+
createHit := false
106+
srv := selectServer(t, &createHit)
107+
defer srv.Close()
108+
109+
cfg := &test.ConfigStub{
110+
SavedApps: map[string]test.SavedApplication{
111+
"APP1": {Alias: "my app", APIKeyUUID: "existing-uuid", APIKey: "old-key"},
112+
},
113+
}
114+
opts := newSelectOpts(t, srv, cfg)
115+
// A key in the keychain lets ReuseExistingAPIKey succeed.
116+
require.NoError(t, keychain.SaveAppSecrets("APP1", keychain.AppSecrets{APIKey: "kc-key"}))
117+
118+
app, err := runSelectCmd(opts)
119+
require.NoError(t, err)
120+
require.NotNil(t, app)
121+
122+
assert.False(t, createHit, "expected no new key when a UUID is already stored")
123+
assert.Equal(t, "existing-uuid", cfg.SavedApps["APP1"].APIKeyUUID)
124+
}

0 commit comments

Comments
 (0)