Skip to content

Commit 1d35a3d

Browse files
committed
ROX-31495: wiremock for central (#32)
Signed-off-by: Tomasz Janiszewski <tomek@redhat.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Mladen Todorovic <mtodor@gmail.com> Add Go integration tests for MCP server with WireMock Implements integration tests that verify MCP server functionality using stdio transport and WireMock as a mock StackRox Central backend. **Key Changes:** - Created integration test suite in `integration/` with build tag - Implemented stdio-based MCP client in `internal/testutil/mcp.go` - Added WireMock readiness check in `internal/testutil/wiremock.go` - Added test fixtures for expected WireMock response data - Updated Makefile with integration test targets - Updated GitHub Actions workflow to run integration tests in CI **Test Coverage:** - MCP protocol (initialize, list tools) - Tool invocations (list_clusters, get_deployments_for_cve, etc.) - Error handling (missing parameters) - Success and error scenarios Tests use stdio transport for simplicity and better control over the MCP server lifecycle. Each test starts a fresh MCP server subprocess and communicates via JSON-RPC over stdin/stdout. TODO: Fix WireMock request matching for CVE-2021-44228 deployment query Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> fix: Fix WireMock JSONPath patterns for deployment CVE queries Apply the same JSONPath fix from commit 01f58ab to deployments.json. The original mappings used $.query[?(@.query =~ ...)] which looked for a nested array structure, but gRPC protobuf-to-JSON conversion creates a simple object with a 'query' field (lowercase). Changed all deployment CVE mappings from: $.query[?(@.query =~ /.*CVE-XXX.*/)] to: $[?(@.query =~ /.*CVE-XXX.*/)] This fixes the TestIntegration_GetDeploymentsForCVE_Log4Shell test which was failing because WireMock couldn't match the gRPC requests. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> test: Unskip Log4Shell integration test The WireMock JSONPath fix for deployments.json resolves the issue that was causing this test to fail. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> refactor: Simplify integration tests and remove manual protoc installation This commit implements two major simplifications to the test infrastructure: 1. **Eliminate TestMain Pre-compilation Pattern**: - Extract main() body into new internal/app package with Run() function - Tests now call app.Run() in-process via io.Pipe() instead of subprocess - Removes 60+ lines of build/setup code from integration_test.go - Enables full code coverage (previously main.go had 0% coverage) - Faster test execution (no binary compilation overhead) - Better debugging (direct function calls vs exec) 2. **Remove Manual Protoc Installation from GitHub Actions**: - Delete manual protoc 3.20.1 download from workflow - Rely on Makefile's automatic protoc 32.1 installation - Single source of truth for protoc version - Eliminates version mismatch between CI and local dev Changes: - Create internal/app/app.go with Run() function extracted from main() - Update server.Start() to accept optional stdin/stdout parameters - Refactor testutil.NewMCPClient() to use ServerRunFunc callback - Remove TestMain, buildMCPBinary, global vars from integration tests - Update all integration test functions to use createMCPClient() helper - Remove "Install protoc" step from .github/workflows/test.yml Benefits: - 43 net lines removed (-161 +118) - Better code coverage - Simpler test maintenance - Aligned with Go testing best practices Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> fix: Add missing retry/timeout config to integration test setup The test config was missing RequestTimeout, MaxRetries, InitialBackoff, and MaxBackoff fields, causing the gRPC client to use zero values instead of the defaults (30s timeout, 3 retries). With MaxRetries=0, the retry loop never executed: `for attempt := range 0` This caused "Request failed after all retries ... attempts=0" warnings and empty responses from WireMock. Solution: Explicitly set the retry/timeout fields to match the defaults defined in internal/config/config.go. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Tomasz Janiszewski <tomek@redhat.com> Add Go integration tests for MCP server with WireMock Implements integration tests that verify MCP server functionality using stdio transport and WireMock as a mock StackRox Central backend. **Key Changes:** - Created integration test suite in `integration/` with build tag - Implemented stdio-based MCP client in `internal/testutil/mcp.go` - Added WireMock readiness check in `internal/testutil/wiremock.go` - Added test fixtures for expected WireMock response data - Updated Makefile with integration test targets - Updated GitHub Actions workflow to run integration tests in CI **Test Coverage:** - MCP protocol (initialize, list tools) - Tool invocations (list_clusters, get_deployments_for_cve, etc.) - Error handling (missing parameters) - Success and error scenarios Tests use stdio transport for simplicity and better control over the MCP server lifecycle. Each test starts a fresh MCP server subprocess and communicates via JSON-RPC over stdin/stdout. TODO: Fix WireMock request matching for CVE-2021-44228 deployment query Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> fix: Fix WireMock JSONPath patterns for deployment CVE queries Apply the same JSONPath fix from commit 01f58ab to deployments.json. The original mappings used $.query[?(@.query =~ ...)] which looked for a nested array structure, but gRPC protobuf-to-JSON conversion creates a simple object with a 'query' field (lowercase). Changed all deployment CVE mappings from: $.query[?(@.query =~ /.*CVE-XXX.*/)] to: $[?(@.query =~ /.*CVE-XXX.*/)] This fixes the TestIntegration_GetDeploymentsForCVE_Log4Shell test which was failing because WireMock couldn't match the gRPC requests. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> test: Unskip Log4Shell integration test The WireMock JSONPath fix for deployments.json resolves the issue that was causing this test to fail. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> refactor: Simplify integration tests and remove manual protoc installation This commit implements two major simplifications to the test infrastructure: 1. **Eliminate TestMain Pre-compilation Pattern**: - Extract main() body into new internal/app package with Run() function - Tests now call app.Run() in-process via io.Pipe() instead of subprocess - Removes 60+ lines of build/setup code from integration_test.go - Enables full code coverage (previously main.go had 0% coverage) - Faster test execution (no binary compilation overhead) - Better debugging (direct function calls vs exec) 2. **Remove Manual Protoc Installation from GitHub Actions**: - Delete manual protoc 3.20.1 download from workflow - Rely on Makefile's automatic protoc 32.1 installation - Single source of truth for protoc version - Eliminates version mismatch between CI and local dev Changes: - Create internal/app/app.go with Run() function extracted from main() - Update server.Start() to accept optional stdin/stdout parameters - Refactor testutil.NewMCPClient() to use ServerRunFunc callback - Remove TestMain, buildMCPBinary, global vars from integration tests - Update all integration test functions to use createMCPClient() helper - Remove "Install protoc" step from .github/workflows/test.yml Benefits: - 43 net lines removed (-161 +118) - Better code coverage - Simpler test maintenance - Aligned with Go testing best practices Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> fix: Add missing retry/timeout config to integration test setup The test config was missing RequestTimeout, MaxRetries, InitialBackoff, and MaxBackoff fields, causing the gRPC client to use zero values instead of the defaults (30s timeout, 3 retries). With MaxRetries=0, the retry loop never executed: `for attempt := range 0` This caused "Request failed after all retries ... attempts=0" warnings and empty responses from WireMock. Solution: Explicitly set the retry/timeout fields to match the defaults defined in internal/config/config.go. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Tomasz Janiszewski <tomek@redhat.com> cleanup Signed-off-by: Tomasz Janiszewski <tomek@redhat.com> Refactor integration tests and migrate to official MCP SDK This commit refactors the integration test suite and replaces the custom MCP client implementation with the official MCP Go SDK. Changes: 1. Table-Driven Tests Refactoring: - Consolidated 5 individual test functions into 2 table-driven tests - TestIntegration_ToolCalls: 4 successful tool call scenarios - TestIntegration_ToolCallErrors: error handling scenarios - Reduced code duplication by ~50 lines - Added helper functions: setupInitializedClient, callToolAndGetResult 2. Removed TestMain: - Eliminated WireMock readiness check from TestMain - Removed unused imports (fmt, os) - Simplified test setup (13 lines removed) 3. Migrated to Official MCP Go SDK: - Replaced custom internal/testutil/mcp.go (202 lines) - Created internal/testutil/mcp_client.go (141 lines) using SDK - Uses official mcp.Client and mcp.ClientSession - Proper type-safe content handling with *mcp.TextContent - Better error handling (protocol vs tool errors) Benefits: - Total code reduction: 99 lines removed - Better maintainability with table-driven tests - Future-proof with official SDK - Consistent with server-side SDK usage - All tests pass (3 test functions, 5 subtests total) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> fmt Signed-off-by: Tomasz Janiszewski <tomek@redhat.com>
1 parent 9bf4946 commit 1d35a3d

11 files changed

Lines changed: 498 additions & 54 deletions

File tree

.github/workflows/test.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,39 @@ jobs:
4545
- name: Run E2E smoke test
4646
run: make e2e-smoke-test
4747

48+
- name: Set up Java
49+
uses: actions/setup-java@v4
50+
with:
51+
distribution: 'temurin'
52+
java-version: '11'
53+
54+
- name: Setup proto files
55+
run: ./scripts/setup-proto-files.sh
56+
57+
- name: Generate proto descriptors
58+
run: make proto-generate
59+
60+
- name: Download WireMock
61+
run: make mock-download
62+
63+
- name: Start WireMock
64+
run: make mock-start
65+
66+
- name: Run integration tests
67+
run: make test-integration-coverage
68+
69+
- name: Stop WireMock
70+
if: always()
71+
run: make mock-stop
72+
73+
- name: Upload WireMock logs on failure
74+
if: failure()
75+
uses: actions/upload-artifact@v4
76+
with:
77+
name: wiremock-logs
78+
path: wiremock/wiremock.log
79+
if-no-files-found: ignore
80+
4881
- name: Upload test results to Codecov
4982
uses: codecov/test-results-action@v1
5083
with:
@@ -56,3 +89,14 @@ jobs:
5689
files: ./coverage.out
5790
token: ${{ secrets.CODECOV_TOKEN }}
5891
fail_ci_if_error: false
92+
flags: unit
93+
name: unit-tests
94+
95+
- name: Upload integration test coverage to Codecov
96+
uses: codecov/codecov-action@v5
97+
with:
98+
file: ./coverage-integration.out
99+
token: ${{ secrets.CODECOV_TOKEN }}
100+
fail_ci_if_error: false
101+
flags: integration
102+
name: integration-tests

cmd/stackrox-mcp/main.go

Lines changed: 3 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,12 @@ package main
44
import (
55
"context"
66
"flag"
7-
"log/slog"
8-
"os"
9-
"os/signal"
10-
"syscall"
117

12-
"github.com/stackrox/stackrox-mcp/internal/client"
8+
"github.com/stackrox/stackrox-mcp/internal/app"
139
"github.com/stackrox/stackrox-mcp/internal/config"
1410
"github.com/stackrox/stackrox-mcp/internal/logging"
15-
"github.com/stackrox/stackrox-mcp/internal/server"
16-
"github.com/stackrox/stackrox-mcp/internal/toolsets"
17-
toolsetConfig "github.com/stackrox/stackrox-mcp/internal/toolsets/config"
18-
toolsetVulnerability "github.com/stackrox/stackrox-mcp/internal/toolsets/vulnerability"
1911
)
2012

21-
// getToolsets initializes and returns all available toolsets.
22-
func getToolsets(cfg *config.Config, c *client.Client) []toolsets.Toolset {
23-
return []toolsets.Toolset{
24-
toolsetConfig.NewToolset(cfg, c),
25-
toolsetVulnerability.NewToolset(cfg, c),
26-
}
27-
}
28-
2913
func main() {
3014
logging.SetupLogging()
3115

@@ -38,38 +22,9 @@ func main() {
3822
logging.Fatal("Failed to load configuration", err)
3923
}
4024

41-
// Log full configuration with sensitive data redacted.
42-
slog.Info("Configuration loaded successfully", "config", cfg.Redacted())
43-
44-
stackroxClient, err := client.NewClient(&cfg.Central)
45-
if err != nil {
46-
logging.Fatal("Failed to create StackRox client", err)
47-
}
48-
49-
registry := toolsets.NewRegistry(cfg, getToolsets(cfg, stackroxClient))
50-
srv := server.NewServer(cfg, registry)
51-
52-
// Set up context with signal handling for graceful shutdown.
53-
ctx, cancel := context.WithCancel(context.Background())
54-
defer cancel()
55-
56-
err = stackroxClient.Connect(ctx)
57-
if err != nil {
58-
logging.Fatal("Failed to connect to StackRox server", err)
59-
}
60-
61-
sigChan := make(chan os.Signal, 1)
62-
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
63-
64-
go func() {
65-
<-sigChan
66-
slog.Info("Received shutdown signal")
67-
cancel()
68-
}()
69-
70-
slog.Info("Starting StackRox MCP server")
25+
ctx := context.Background()
7126

72-
if err := srv.Start(ctx); err != nil {
27+
if err := app.Run(ctx, cfg, nil, nil); err != nil {
7328
logging.Fatal("Server error", err)
7429
}
7530
}

cmd/stackrox-mcp/main_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,20 @@ import (
1414
"github.com/stackrox/stackrox-mcp/internal/server"
1515
"github.com/stackrox/stackrox-mcp/internal/testutil"
1616
"github.com/stackrox/stackrox-mcp/internal/toolsets"
17+
toolsetConfig "github.com/stackrox/stackrox-mcp/internal/toolsets/config"
18+
toolsetVulnerability "github.com/stackrox/stackrox-mcp/internal/toolsets/vulnerability"
1719
"github.com/stretchr/testify/assert"
1820
"github.com/stretchr/testify/require"
1921
)
2022

23+
// getToolsets initializes and returns all available toolsets.
24+
func getToolsets(cfg *config.Config, c *client.Client) []toolsets.Toolset {
25+
return []toolsets.Toolset{
26+
toolsetConfig.NewToolset(cfg, c),
27+
toolsetVulnerability.NewToolset(cfg, c),
28+
}
29+
}
30+
2131
func TestGetToolsets(t *testing.T) {
2232
allToolsets := getToolsets(&config.Config{}, &client.Client{})
2333

@@ -46,7 +56,7 @@ func TestGracefulShutdown(t *testing.T) {
4656
errChan := make(chan error, 1)
4757

4858
go func() {
49-
errChan <- srv.Start(ctx)
59+
errChan <- srv.Start(ctx, nil, nil)
5060
}()
5161

5262
serverURL := "http://" + net.JoinHostPort(cfg.Server.Address, strconv.Itoa(cfg.Server.Port))

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/stretchr/testify v1.11.1
1212
golang.stackrox.io/grpc-http1 v0.5.1
1313
google.golang.org/grpc v1.79.2
14+
google.golang.org/protobuf v1.36.10
1415
)
1516

1617
require (
@@ -40,7 +41,6 @@ require (
4041
golang.org/x/text v0.32.0 // indirect
4142
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
4243
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
43-
google.golang.org/protobuf v1.36.10 // indirect
4444
gopkg.in/yaml.v3 v3.0.1 // indirect
4545
)
4646

integration/fixtures.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//go:build integration
2+
3+
package integration
4+
5+
// Log4ShellFixture contains expected data from log4j_cve.json fixture.
6+
var Log4ShellFixture = struct {
7+
CVEName string
8+
DeploymentCount int
9+
DeploymentNames []string
10+
}{
11+
CVEName: "CVE-2021-44228",
12+
DeploymentCount: 3,
13+
DeploymentNames: []string{"elasticsearch", "kafka-broker", "spring-boot-app"},
14+
}
15+
16+
// AllClustersFixture contains expected data from all_clusters.json fixture.
17+
var AllClustersFixture = struct {
18+
TotalCount int
19+
ClusterNames []string
20+
}{
21+
TotalCount: 5,
22+
ClusterNames: []string{
23+
"production-cluster",
24+
"staging-cluster",
25+
"staging-central-cluster",
26+
"development-cluster",
27+
"production-cluster-eu",
28+
},
29+
}

integration/integration_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
//go:build integration
2+
3+
package integration
4+
5+
import (
6+
"context"
7+
"io"
8+
"testing"
9+
"time"
10+
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
12+
"github.com/stackrox/stackrox-mcp/internal/app"
13+
"github.com/stackrox/stackrox-mcp/internal/config"
14+
"github.com/stackrox/stackrox-mcp/internal/testutil"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// setupInitializedClient creates an initialized MCP client for testing.
20+
func setupInitializedClient(t *testing.T) *testutil.MCPTestClient {
21+
t.Helper()
22+
23+
client, err := createMCPClient(t)
24+
require.NoError(t, err, "Failed to create MCP client")
25+
t.Cleanup(func() { client.Close() })
26+
27+
return client
28+
}
29+
30+
// callToolAndGetResult calls a tool and verifies it succeeds.
31+
func callToolAndGetResult(t *testing.T, client *testutil.MCPTestClient, toolName string, args map[string]any) *mcp.CallToolResult {
32+
t.Helper()
33+
34+
ctx := context.Background()
35+
result, err := client.CallTool(ctx, toolName, args)
36+
require.NoError(t, err)
37+
testutil.RequireNoError(t, result)
38+
39+
return result
40+
}
41+
42+
// getTextContent extracts text from the first content item.
43+
func getTextContent(t *testing.T, result *mcp.CallToolResult) string {
44+
t.Helper()
45+
require.NotEmpty(t, result.Content, "should have content in response")
46+
47+
textContent, ok := result.Content[0].(*mcp.TextContent)
48+
require.True(t, ok, "expected TextContent, got %T", result.Content[0])
49+
50+
return textContent.Text
51+
}
52+
53+
// TestIntegration_ListTools verifies that all expected tools are registered.
54+
func TestIntegration_ListTools(t *testing.T) {
55+
client := setupInitializedClient(t)
56+
57+
ctx := context.Background()
58+
result, err := client.ListTools(ctx)
59+
require.NoError(t, err)
60+
61+
// Verify we have tools registered
62+
assert.NotEmpty(t, result.Tools, "should have tools registered")
63+
64+
// Check for specific tools we expect
65+
toolNames := make([]string, 0, len(result.Tools))
66+
for _, tool := range result.Tools {
67+
toolNames = append(toolNames, tool.Name)
68+
}
69+
70+
assert.Contains(t, toolNames, "get_deployments_for_cve", "should have get_deployments_for_cve tool")
71+
assert.Contains(t, toolNames, "list_clusters", "should have list_clusters tool")
72+
}
73+
74+
// TestIntegration_ToolCalls tests successful tool calls using table-driven tests.
75+
func TestIntegration_ToolCalls(t *testing.T) {
76+
tests := map[string]struct {
77+
toolName string
78+
args map[string]any
79+
expectedInText []string // strings that must appear in response
80+
}{
81+
"get_deployments_for_cve with Log4Shell": {
82+
toolName: "get_deployments_for_cve",
83+
args: map[string]any{"cveName": Log4ShellFixture.CVEName},
84+
expectedInText: Log4ShellFixture.DeploymentNames,
85+
},
86+
"get_deployments_for_cve with non-existent CVE": {
87+
toolName: "get_deployments_for_cve",
88+
args: map[string]any{"cveName": "CVE-9999-99999"},
89+
expectedInText: []string{`"deployments":[]`},
90+
},
91+
"list_clusters": {
92+
toolName: "list_clusters",
93+
args: map[string]any{},
94+
expectedInText: AllClustersFixture.ClusterNames,
95+
},
96+
"get_clusters_with_orchestrator_cve": {
97+
toolName: "get_clusters_with_orchestrator_cve",
98+
args: map[string]any{"cveName": "CVE-2099-00001"},
99+
expectedInText: []string{`"clusters":`},
100+
},
101+
}
102+
103+
for name, tt := range tests {
104+
t.Run(name, func(t *testing.T) {
105+
client := setupInitializedClient(t)
106+
result := callToolAndGetResult(t, client, tt.toolName, tt.args)
107+
108+
responseText := getTextContent(t, result)
109+
for _, expected := range tt.expectedInText {
110+
assert.Contains(t, responseText, expected)
111+
}
112+
})
113+
}
114+
}
115+
116+
// TestIntegration_ToolCallErrors tests error handling using table-driven tests.
117+
func TestIntegration_ToolCallErrors(t *testing.T) {
118+
tests := map[string]struct {
119+
toolName string
120+
args map[string]any
121+
expectedErrorMsg string
122+
}{
123+
"get_deployments_for_cve missing CVE name": {
124+
toolName: "get_deployments_for_cve",
125+
args: map[string]any{},
126+
expectedErrorMsg: "cveName",
127+
},
128+
}
129+
130+
for name, tt := range tests {
131+
t.Run(name, func(t *testing.T) {
132+
client := setupInitializedClient(t)
133+
134+
ctx := context.Background()
135+
_, err := client.CallTool(ctx, tt.toolName, tt.args)
136+
137+
// Validation errors are returned as protocol errors, not tool errors
138+
require.Error(t, err, "should receive protocol error for invalid params")
139+
assert.Contains(t, err.Error(), tt.expectedErrorMsg)
140+
})
141+
}
142+
}
143+
144+
// createTestConfig creates a test configuration for the MCP server.
145+
func createTestConfig() *config.Config {
146+
return &config.Config{
147+
Central: config.CentralConfig{
148+
URL: "localhost:8081",
149+
AuthType: "static",
150+
APIToken: "test-token-admin",
151+
InsecureSkipTLSVerify: true,
152+
RequestTimeout: 30 * time.Second,
153+
MaxRetries: 3,
154+
InitialBackoff: time.Second,
155+
MaxBackoff: 10 * time.Second,
156+
},
157+
Server: config.ServerConfig{
158+
Type: config.ServerTypeStdio,
159+
},
160+
Tools: config.ToolsConfig{
161+
Vulnerability: config.ToolsetVulnerabilityConfig{
162+
Enabled: true,
163+
},
164+
ConfigManager: config.ToolConfigManagerConfig{
165+
Enabled: true,
166+
},
167+
},
168+
}
169+
}
170+
171+
// createMCPClient is a helper function that creates an MCP client with the test configuration.
172+
func createMCPClient(t *testing.T) (*testutil.MCPTestClient, error) {
173+
t.Helper()
174+
175+
cfg := createTestConfig()
176+
177+
// Create a run function that wraps app.Run with the config
178+
runFunc := func(ctx context.Context, stdin io.ReadCloser, stdout io.WriteCloser) error {
179+
return app.Run(ctx, cfg, stdin, stdout)
180+
}
181+
182+
return testutil.NewMCPTestClient(t, runFunc)
183+
}

0 commit comments

Comments
 (0)