Skip to content

Commit 976cf69

Browse files
committed
EPMDEDP-16652: test: Add comprehensive unit tests for login cluster config
Refactored deeply nested cluster config population logic into separate functions (populateClusterConfig, fetchClusterMetadata) for improved testability and maintainability. Fixes regression where namespace warning was swallowed by early returns on errors. Signed-off-by: Sergiy Kulanov <sergiy_kulanov@epam.com>
1 parent 9ad0f8a commit 976cf69

4 files changed

Lines changed: 213 additions & 54 deletions

File tree

README.md

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -192,50 +192,34 @@ port 3001 directly.
192192
make build # → dist/krci
193193
```
194194

195-
### Step 1 — Login
195+
### Login
196196

197-
The portal runs over HTTP, so the CLI cannot auto-discover the OIDC issuer.
198-
Pass it explicitly via `KRCI_ISSUER_URL` (copy the value from the portal's
199-
`OIDC_ISSUER_URL` in `.env` or from its startup output):
197+
The portal runs over HTTP, so the CLI cannot auto-discover the OIDC issuer or
198+
cluster metadata. Supply all values upfront via environment variables (copy
199+
`OIDC_ISSUER_URL` from the portal's `.env` or its startup output):
200200

201201
```bash
202202
KRCI_ISSUER_URL=https://idp.example.com/realms/my-realm \
203+
KRCI_CLUSTER_NAME=my-cluster \
204+
KRCI_NAMESPACE=my-namespace \
203205
./dist/krci auth login --portal-url http://localhost:3001
204206
```
205207

206-
A browser window opens for authentication. On success you'll see:
208+
A browser window opens for authentication. On success:
207209

208210
```
209211
Logged in as user@example.com (User Name)
210-
Warning: could not fetch cluster config: portal URL must use HTTPS ...
211-
Warning: namespace not configured; set KRCI_NAMESPACE or re-run login
212212
```
213213

214-
These warnings are expected — the CLI cannot fetch cluster metadata over HTTP.
215-
The portal URL and issuer are saved to `~/.config/krci/config.yaml`, so you
216-
won't need `--portal-url` on subsequent commands.
217-
218-
### Step 2 — Run commands
219-
220-
Supply the cluster name and namespace that would normally be auto-discovered.
221-
These are required on every command:
222-
223-
```bash
224-
export KRCI_CLUSTER_NAME=my-cluster
225-
export KRCI_NAMESPACE=my-namespace
226-
```
227-
228-
Then use the CLI normally:
214+
All values are saved to `~/.config/krci/config.yaml`, so subsequent commands
215+
need no flags or environment variables:
229216

230217
```bash
231218
./dist/krci project list
232219
./dist/krci deployment list
233220
./dist/krci pipelinerun list --project my-app --reason
234221
```
235222

236-
> **Tip:** add the exports to a local `.envrc` (direnv) or a shell alias to
237-
> avoid repeating them.
238-
239223
## Prerequisites
240224

241225
- Go 1.26+ (for building from source)

internal/config/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ func Resolve() (*Config, error) {
8989
return &cfg, nil
9090
}
9191

92-
// Save persists user-provided values to the config file.
93-
// Only writes non-empty values so defaults and env vars are not baked in.
92+
// Save persists resolved configuration values to the config file.
93+
// Non-empty values (including those resolved from env vars) are written
94+
// so they survive across sessions without requiring env vars again.
9495
func Save(cfg *Config) error {
9596
configDir := cfg.ConfigDir
9697
if err := os.MkdirAll(configDir, 0700); err != nil {

pkg/cmd/auth/login/login.go

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
package login
33

44
import (
5+
"context"
56
"errors"
67
"fmt"
8+
"io"
79
"regexp"
810

911
"github.com/spf13/cobra"
@@ -18,6 +20,9 @@ import (
1820
// dns1123LabelRegexp validates a Kubernetes namespace name.
1921
var dns1123LabelRegexp = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
2022

23+
// clusterFetchFunc fetches cluster configuration from a portal URL using a bearer token.
24+
type clusterFetchFunc func(portalURL, token string) (*portal.ClusterConfig, error)
25+
2126
// LoginOptions holds all inputs for the login command.
2227
type LoginOptions struct {
2328
IO *iostreams.IOStreams
@@ -107,39 +112,60 @@ func loginRun(cmd *cobra.Command, opts *LoginOptions) error {
107112
// Clone cfg so Save() writes only the fields we intend to persist.
108113
cfgCopy := *cfg
109114

110-
// Fetch cluster config (authenticated) to auto-populate cluster name and namespace.
111-
tok, err := tp.GetToken(cmd.Context())
112-
if err != nil {
113-
_, _ = fmt.Fprintf(opts.IO.ErrOut, "Warning: could not get token for cluster config: %v\n", err)
114-
} else {
115-
clusterCfg, err := portal.FetchClusterConfig(cfg.PortalURL, tok)
116-
if err != nil {
117-
_, _ = fmt.Fprintf(opts.IO.ErrOut, "Warning: could not fetch cluster config: %v\n", err)
118-
} else {
119-
if cfgCopy.ClusterName == "" && clusterCfg.ClusterName != "" {
120-
cfgCopy.ClusterName = clusterCfg.ClusterName
121-
}
115+
populateClusterConfig(cmd.Context(), tp, &cfgCopy, opts.IO.ErrOut, portal.FetchClusterConfig)
122116

123-
if cfgCopy.Namespace == "" && clusterCfg.DefaultNamespace != "" {
124-
if dns1123LabelRegexp.MatchString(clusterCfg.DefaultNamespace) {
125-
cfgCopy.Namespace = clusterCfg.DefaultNamespace
126-
_, _ = fmt.Fprintf(opts.IO.ErrOut, "Namespace: %s (from portal)\n", cfgCopy.Namespace)
127-
} else {
128-
_, _ = fmt.Fprintf(opts.IO.ErrOut,
129-
"Warning: portal returned invalid namespace %q, ignoring\n", clusterCfg.DefaultNamespace)
130-
}
131-
}
132-
}
117+
if err := config.Save(&cfgCopy); err != nil {
118+
_, _ = fmt.Fprintf(opts.IO.ErrOut, "Warning: could not save config: %v\n", err)
119+
}
120+
121+
return nil
122+
}
123+
124+
// populateClusterConfig fetches cluster name and namespace from the portal
125+
// when they are not already provided via env vars or config file.
126+
func populateClusterConfig(
127+
ctx context.Context, tp auth.TokenProvider, cfg *config.Config,
128+
errOut io.Writer, fetch clusterFetchFunc,
129+
) {
130+
if cfg.ClusterName != "" && cfg.Namespace != "" {
131+
return
133132
}
134133

135-
if cfgCopy.Namespace == "" {
136-
_, _ = fmt.Fprintf(opts.IO.ErrOut,
134+
fetchClusterMetadata(ctx, tp, cfg, errOut, fetch)
135+
136+
if cfg.Namespace == "" {
137+
_, _ = fmt.Fprintf(errOut,
137138
"Warning: namespace not configured; set KRCI_NAMESPACE or re-run login\n")
138139
}
140+
}
139141

140-
if err := config.Save(&cfgCopy); err != nil {
141-
_, _ = fmt.Fprintf(opts.IO.ErrOut, "Warning: could not save config: %v\n", err)
142+
func fetchClusterMetadata(
143+
ctx context.Context, tp auth.TokenProvider, cfg *config.Config,
144+
errOut io.Writer, fetch clusterFetchFunc,
145+
) {
146+
tok, err := tp.GetToken(ctx)
147+
if err != nil {
148+
_, _ = fmt.Fprintf(errOut, "Warning: could not get token for cluster config: %v\n", err)
149+
return
142150
}
143151

144-
return nil
152+
clusterCfg, err := fetch(cfg.PortalURL, tok)
153+
if err != nil {
154+
_, _ = fmt.Fprintf(errOut, "Warning: could not fetch cluster config: %v\n", err)
155+
return
156+
}
157+
158+
if cfg.ClusterName == "" && clusterCfg.ClusterName != "" {
159+
cfg.ClusterName = clusterCfg.ClusterName
160+
}
161+
162+
if cfg.Namespace == "" && clusterCfg.DefaultNamespace != "" {
163+
if dns1123LabelRegexp.MatchString(clusterCfg.DefaultNamespace) {
164+
cfg.Namespace = clusterCfg.DefaultNamespace
165+
_, _ = fmt.Fprintf(errOut, "Namespace: %s (from portal)\n", cfg.Namespace)
166+
} else {
167+
_, _ = fmt.Fprintf(errOut,
168+
"Warning: portal returned invalid namespace %q, ignoring\n", clusterCfg.DefaultNamespace)
169+
}
170+
}
145171
}

pkg/cmd/auth/login/login_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package login
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
11+
"github.com/KubeRocketCI/cli/internal/auth"
12+
"github.com/KubeRocketCI/cli/internal/config"
13+
"github.com/KubeRocketCI/cli/internal/portal"
14+
)
15+
16+
// mockTokenProvider implements auth.TokenProvider for testing.
17+
type mockTokenProvider struct {
18+
token string
19+
tokenErr error
20+
}
21+
22+
func (m *mockTokenProvider) GetToken(_ context.Context) (string, error) {
23+
return m.token, m.tokenErr
24+
}
25+
26+
func (m *mockTokenProvider) Login(_ context.Context) error { return nil }
27+
func (m *mockTokenProvider) Logout() error { return nil }
28+
func (m *mockTokenProvider) UserInfo() (*auth.UserInfo, error) { return nil, nil }
29+
30+
func TestPopulateClusterConfig(t *testing.T) {
31+
t.Parallel()
32+
33+
okFetcher := func(cluster, ns string) clusterFetchFunc {
34+
return func(_, _ string) (*portal.ClusterConfig, error) {
35+
return &portal.ClusterConfig{ClusterName: cluster, DefaultNamespace: ns}, nil
36+
}
37+
}
38+
39+
errFetcher := func(_, _ string) (*portal.ClusterConfig, error) {
40+
return nil, errors.New("connection refused")
41+
}
42+
43+
tests := []struct {
44+
name string
45+
cfg config.Config
46+
tp auth.TokenProvider
47+
fetcher clusterFetchFunc
48+
wantCluster string
49+
wantNS string
50+
wantOutput []string
51+
wantNoOutput []string
52+
}{
53+
{
54+
name: "both already set — skips fetch",
55+
cfg: config.Config{
56+
ClusterName: "existing",
57+
Namespace: "existing-ns",
58+
},
59+
tp: &mockTokenProvider{token: "tok"},
60+
fetcher: errFetcher,
61+
wantCluster: "existing",
62+
wantNS: "existing-ns",
63+
wantNoOutput: []string{"Warning"},
64+
},
65+
{
66+
name: "fetch succeeds — populates both",
67+
cfg: config.Config{PortalURL: "https://portal.test"},
68+
tp: &mockTokenProvider{token: "tok"},
69+
fetcher: okFetcher("discovered", "discovered-ns"),
70+
wantCluster: "discovered",
71+
wantNS: "discovered-ns",
72+
wantOutput: []string{"Namespace: discovered-ns (from portal)"},
73+
},
74+
{
75+
name: "fetch succeeds — does not overwrite existing cluster name",
76+
cfg: config.Config{PortalURL: "https://portal.test", ClusterName: "existing"},
77+
tp: &mockTokenProvider{token: "tok"},
78+
fetcher: okFetcher("discovered", "discovered-ns"),
79+
wantCluster: "existing",
80+
wantNS: "discovered-ns",
81+
},
82+
{
83+
name: "fetch succeeds — does not overwrite existing namespace",
84+
cfg: config.Config{PortalURL: "https://portal.test", Namespace: "existing-ns"},
85+
tp: &mockTokenProvider{token: "tok"},
86+
fetcher: okFetcher("discovered", "other-ns"),
87+
wantCluster: "discovered",
88+
wantNS: "existing-ns",
89+
},
90+
{
91+
name: "token error — warns and emits namespace warning",
92+
cfg: config.Config{PortalURL: "https://portal.test"},
93+
tp: &mockTokenProvider{tokenErr: errors.New("token expired")},
94+
fetcher: errFetcher,
95+
wantCluster: "",
96+
wantNS: "",
97+
wantOutput: []string{
98+
"Warning: could not get token",
99+
"Warning: namespace not configured",
100+
},
101+
},
102+
{
103+
name: "fetch error — warns and emits namespace warning",
104+
cfg: config.Config{PortalURL: "https://portal.test"},
105+
tp: &mockTokenProvider{token: "tok"},
106+
fetcher: errFetcher,
107+
wantCluster: "",
108+
wantNS: "",
109+
wantOutput: []string{
110+
"Warning: could not fetch cluster config",
111+
"Warning: namespace not configured",
112+
},
113+
},
114+
{
115+
name: "invalid namespace from portal — warns",
116+
cfg: config.Config{PortalURL: "https://portal.test"},
117+
tp: &mockTokenProvider{token: "tok"},
118+
fetcher: okFetcher("c", "INVALID NS!"),
119+
wantCluster: "c",
120+
wantNS: "",
121+
wantOutput: []string{
122+
"Warning: portal returned invalid namespace",
123+
"Warning: namespace not configured",
124+
},
125+
},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
t.Parallel()
131+
132+
cfg := tt.cfg
133+
var buf bytes.Buffer
134+
populateClusterConfig(context.Background(), tt.tp, &cfg, &buf, tt.fetcher)
135+
136+
assert.Equal(t, tt.wantCluster, cfg.ClusterName, "ClusterName")
137+
assert.Equal(t, tt.wantNS, cfg.Namespace, "Namespace")
138+
139+
output := buf.String()
140+
for _, want := range tt.wantOutput {
141+
assert.Contains(t, output, want)
142+
}
143+
for _, notWant := range tt.wantNoOutput {
144+
assert.NotContains(t, output, notWant)
145+
}
146+
})
147+
}
148+
}

0 commit comments

Comments
 (0)