-
Notifications
You must be signed in to change notification settings - Fork 207
Add unit tests for pkg/vmcp/cli serve and validate logic #4898
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: issue-4879
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,210 @@ | ||||||||||||||||||||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||||||||||||||||||||
| // SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||
|
|
||||||||||||||||||||
| package cli | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import ( | ||||||||||||||||||||
| "os" | ||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||
| "testing" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| "github.com/stretchr/testify/assert" | ||||||||||||||||||||
| "github.com/stretchr/testify/require" | ||||||||||||||||||||
| "go.uber.org/mock/gomock" | ||||||||||||||||||||
| "gopkg.in/yaml.v3" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| envmocks "github.com/stacklok/toolhive-core/env/mocks" | ||||||||||||||||||||
| authserverconfig "github.com/stacklok/toolhive/pkg/authserver" | ||||||||||||||||||||
| aggregatormocks "github.com/stacklok/toolhive/pkg/vmcp/aggregator/mocks" | ||||||||||||||||||||
| clientmocks "github.com/stacklok/toolhive/pkg/vmcp/client/mocks" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // TestLoadAndValidateConfig covers all config-loading paths. | ||||||||||||||||||||
| func TestLoadAndValidateConfig(t *testing.T) { | ||||||||||||||||||||
| t.Parallel() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| tests := []struct { | ||||||||||||||||||||
| name string | ||||||||||||||||||||
| content string | ||||||||||||||||||||
| wantErr bool | ||||||||||||||||||||
| errContains string | ||||||||||||||||||||
| }{ | ||||||||||||||||||||
| { | ||||||||||||||||||||
| name: "valid config", | ||||||||||||||||||||
| content: validConfigYAML, | ||||||||||||||||||||
| wantErr: false, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| { | ||||||||||||||||||||
| name: "non-existent file", | ||||||||||||||||||||
| content: "", // file will not be created | ||||||||||||||||||||
| wantErr: true, | ||||||||||||||||||||
| errContains: "configuration loading failed", | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| { | ||||||||||||||||||||
| name: "malformed YAML", | ||||||||||||||||||||
| content: ":::invalid yaml:::", | ||||||||||||||||||||
| wantErr: true, | ||||||||||||||||||||
| errContains: "configuration loading failed", | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| { | ||||||||||||||||||||
| name: "fails semantic validation — missing groupRef", | ||||||||||||||||||||
| content: ` | ||||||||||||||||||||
| name: test-vmcp | ||||||||||||||||||||
| incomingAuth: | ||||||||||||||||||||
| type: anonymous | ||||||||||||||||||||
| outgoingAuth: | ||||||||||||||||||||
| source: inline | ||||||||||||||||||||
| aggregation: | ||||||||||||||||||||
| conflictResolution: prefix | ||||||||||||||||||||
| `, | ||||||||||||||||||||
| wantErr: true, | ||||||||||||||||||||
| errContains: "validation failed", | ||||||||||||||||||||
|
Comment on lines
+59
to
+61
|
||||||||||||||||||||
| `, | |
| wantErr: true, | |
| errContains: "validation failed", | |
| conflictResolutionConfig: | |
| fieldNamePrefix: | |
| separator: "-" | |
| `, | |
| wantErr: true, | |
| errContains: "group reference is required", |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package cli | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| const validConfigYAML = ` | ||
| name: test-vmcp | ||
| groupRef: test-group | ||
|
|
||
| incomingAuth: | ||
| type: anonymous | ||
|
|
||
| outgoingAuth: | ||
| source: inline | ||
| default: | ||
| type: unauthenticated | ||
|
|
||
| aggregation: | ||
| conflictResolution: prefix | ||
| conflictResolutionConfig: | ||
| prefixFormat: "{workload}_" | ||
| ` | ||
|
|
||
| func TestValidate(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| setup func(t *testing.T) ValidateConfig | ||
| wantErr bool | ||
| errContains string | ||
| }{ | ||
| { | ||
| name: "missing config path", | ||
| setup: func(_ *testing.T) ValidateConfig { | ||
| return ValidateConfig{} | ||
| }, | ||
| wantErr: true, | ||
| errContains: "no configuration file specified", | ||
| }, | ||
| { | ||
| name: "valid config file", | ||
| setup: func(t *testing.T) ValidateConfig { | ||
| t.Helper() | ||
| path := filepath.Join(t.TempDir(), "vmcp.yaml") | ||
| require.NoError(t, os.WriteFile(path, []byte(validConfigYAML), 0o600)) | ||
| return ValidateConfig{ConfigPath: path} | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| name: "non-existent file", | ||
| setup: func(t *testing.T) ValidateConfig { | ||
| t.Helper() | ||
| return ValidateConfig{ConfigPath: filepath.Join(t.TempDir(), "nonexistent.yaml")} | ||
| }, | ||
| wantErr: true, | ||
| errContains: "configuration loading failed", | ||
| }, | ||
| { | ||
| name: "malformed YAML", | ||
| setup: func(t *testing.T) ValidateConfig { | ||
| t.Helper() | ||
| path := filepath.Join(t.TempDir(), "bad.yaml") | ||
| require.NoError(t, os.WriteFile(path, []byte(":::not valid yaml:::"), 0o600)) | ||
| return ValidateConfig{ConfigPath: path} | ||
| }, | ||
| wantErr: true, | ||
| errContains: "configuration loading failed", | ||
| }, | ||
| { | ||
| name: "config missing required groupRef", | ||
| setup: func(t *testing.T) ValidateConfig { | ||
| t.Helper() | ||
| path := filepath.Join(t.TempDir(), "invalid.yaml") | ||
| // groupRef is required; omitting it must fail validation. | ||
| require.NoError(t, os.WriteFile(path, []byte(` | ||
| name: test-vmcp | ||
| incomingAuth: | ||
| type: anonymous | ||
| outgoingAuth: | ||
| source: inline | ||
| aggregation: | ||
| conflictResolution: prefix | ||
| `), 0o600)) | ||
| return ValidateConfig{ConfigPath: path} | ||
| }, | ||
| wantErr: true, | ||
| errContains: "validation failed", | ||
| }, | ||
|
Comment on lines
+81
to
+99
|
||
| } | ||
|
|
||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
| cfg := tc.setup(t) | ||
| err := Validate(context.Background(), cfg) | ||
| if tc.wantErr { | ||
| require.Error(t, err) | ||
| require.ErrorContains(t, err, tc.errContains) | ||
| } else { | ||
| require.NoError(t, err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Consider passing data instead of injecting
env.Reader.createSessionFactoryonly needs two pieces of information from the environment: the HMAC secret string and whether we're running in Kubernetes. Passing those as plain values makes the function's contract explicit in its signature and eliminates mock boilerplate in tests:The caller (
Serve) already has the env reader — it can resolveGetenv("VMCP_SESSION_HMAC_SECRET")andIsKubernetesRuntimeWithEnv(envReader)before calling this function.This avoids tests needing to know which env vars the function checks internally (e.g.,
TOOLHIVE_RUNTIME,KUBERNETES_SERVICE_HOST), which makes them fragile if the runtime detection logic ever adds another variable. Tests become trivial:createSessionFactory("my-secret", false, registry, agg).