|
| 1 | +package server |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "context" |
| 6 | + "encoding/json" |
| 7 | + "net/http" |
| 8 | + "net/http/httptest" |
| 9 | + "testing" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/stretchr/testify/assert" |
| 13 | + "github.com/stretchr/testify/require" |
| 14 | + "go.uber.org/zap" |
| 15 | + |
| 16 | + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" |
| 17 | + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" |
| 18 | + "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi" |
| 19 | +) |
| 20 | + |
| 21 | +// Spec 070 keystone regression (T021 / CN-004 / FR-010 / SC-004). |
| 22 | +// |
| 23 | +// Every add surface (REST, MCP, CLI) funnels through the single keystone |
| 24 | +// AddServerFromRegistryRef, so the registry-result -> config.ServerConfig |
| 25 | +// normalization lives in exactly one place. This test is the guard that keeps |
| 26 | +// it that way: it drives the SAME logical add — same (registry, serverId, name, |
| 27 | +// env) — through each surface against its own isolated server, then asserts the |
| 28 | +// PERSISTED config.ServerConfig is byte-identical across all three (modulo the |
| 29 | +// Created/Updated timestamps) and that every one is quarantined (SC-004). |
| 30 | +// |
| 31 | +// If a future change lets one surface bypass the keystone (e.g. the Web UI's |
| 32 | +// old client-side install_cmd parsing, or a surface that forgets the quarantine |
| 33 | +// default), the persisted configs diverge and this test fails. |
| 34 | +// |
| 35 | +// Surfaces exercised in-process: |
| 36 | +// - MCP: the real upstream_servers handler (operation=add_from_registry), |
| 37 | +// which extracts args from the MCP request (note: env arrives as env_json). |
| 38 | +// - REST: a real HTTP POST to the actual chi router handler |
| 39 | +// (POST /api/v1/registries/{id}/servers/{serverId}/add), exercising JSON |
| 40 | +// body decode + URL param extraction + auth. |
| 41 | +// - CLI add path: the CLI is a thin HTTP client of the REST route, so its |
| 42 | +// config-derivation contribution bottoms out at the same controller method |
| 43 | +// (AddServerFromRegistryRef). The full CLI binary->daemon path is separately |
| 44 | +// covered end-to-end by TestRegistryAddCLIE2E. |
| 45 | +func TestCrossSurfaceConsistency_RegistryAdd(t *testing.T) { |
| 46 | + // One stdio entry whose install command declares a required input |
| 47 | + // (${API_KEY}); supplying it via env exercises required-input satisfaction |
| 48 | + // AND env carry-through on the persisted config. |
| 49 | + servers := []map[string]interface{}{ |
| 50 | + {"id": "everything", "name": "everything", "installCmd": "npx -y srv --key ${API_KEY}"}, |
| 51 | + } |
| 52 | + startTestRegistry(t, servers) // registers id="testreg" against a local httptest server |
| 53 | + |
| 54 | + const ( |
| 55 | + regID = "testreg" |
| 56 | + serverID = "everything" |
| 57 | + addName = "consistency-srv" |
| 58 | + ) |
| 59 | + env := map[string]string{"API_KEY": "secret-123"} |
| 60 | + |
| 61 | + // --- Surface 1: MCP ------------------------------------------------------- |
| 62 | + srvMCP := newConsistencyServer(t) |
| 63 | + proxy := createTestMCPProxyServer(t) |
| 64 | + proxy.mainServer = srvMCP |
| 65 | + |
| 66 | + envJSON, err := json.Marshal(env) |
| 67 | + require.NoError(t, err) |
| 68 | + mcpResult := callAddFromRegistry(t, proxy, map[string]interface{}{ |
| 69 | + "operation": "add_from_registry", |
| 70 | + "registry": regID, |
| 71 | + "id": serverID, |
| 72 | + "name": addName, |
| 73 | + "env_json": string(envJSON), |
| 74 | + }) |
| 75 | + require.False(t, mcpResult.IsError, "MCP add must succeed: %v", mcpResult.Content) |
| 76 | + mcpCfg := persistedServer(t, srvMCP, addName) |
| 77 | + |
| 78 | + // --- Surface 2: REST (real HTTP through the chi router) ------------------- |
| 79 | + srvREST := newConsistencyServer(t) |
| 80 | + api := httpapi.NewServer(srvREST, zap.NewNop().Sugar(), nil) |
| 81 | + |
| 82 | + body, err := json.Marshal(contracts.AddFromRegistryRequest{Name: addName, Env: env}) |
| 83 | + require.NoError(t, err) |
| 84 | + req := httptest.NewRequest(http.MethodPost, |
| 85 | + "/api/v1/registries/"+regID+"/servers/"+serverID+"/add", bytes.NewReader(body)) |
| 86 | + req.Header.Set("Content-Type", "application/json") |
| 87 | + req.Header.Set("X-API-Key", consistencyAPIKey) |
| 88 | + rec := httptest.NewRecorder() |
| 89 | + api.Router().ServeHTTP(rec, req) |
| 90 | + require.Equal(t, http.StatusOK, rec.Code, "REST add must succeed: %s", rec.Body.String()) |
| 91 | + restCfg := persistedServer(t, srvREST, addName) |
| 92 | + |
| 93 | + // --- Surface 3: CLI add path (shared controller bottom) ------------------- |
| 94 | + srvCLI := newConsistencyServer(t) |
| 95 | + cliCfg, rerr, err := srvCLI.AddServerFromRegistryRef(context.Background(), regID, serverID, addName, env, nil) |
| 96 | + require.NoError(t, err) |
| 97 | + require.Nil(t, rerr) |
| 98 | + require.NotNil(t, cliCfg) |
| 99 | + cliPersisted := persistedServer(t, srvCLI, addName) |
| 100 | + |
| 101 | + // --- Cross-surface byte-identity (CN-004) --------------------------------- |
| 102 | + mcpJSON := canonicalServerJSON(t, mcpCfg) |
| 103 | + restJSON := canonicalServerJSON(t, restCfg) |
| 104 | + cliJSON := canonicalServerJSON(t, cliPersisted) |
| 105 | + |
| 106 | + assert.Equal(t, mcpJSON, restJSON, "REST add must persist a byte-identical config to MCP add") |
| 107 | + assert.Equal(t, mcpJSON, cliJSON, "CLI add path must persist a byte-identical config to MCP add") |
| 108 | + |
| 109 | + // --- Quarantine invariant (SC-004 / CN-002) ------------------------------- |
| 110 | + assert.True(t, mcpCfg.Quarantined, "MCP-added server must be quarantined") |
| 111 | + assert.True(t, restCfg.Quarantined, "REST-added server must be quarantined") |
| 112 | + assert.True(t, cliPersisted.Quarantined, "CLI-added server must be quarantined") |
| 113 | + |
| 114 | + // --- Sanity on the shared derivation ------------------------------------- |
| 115 | + assert.Equal(t, "stdio", mcpCfg.Protocol) |
| 116 | + assert.Equal(t, "npx", mcpCfg.Command) |
| 117 | + assert.Equal(t, []string{"-y", "srv", "--key", "${API_KEY}"}, mcpCfg.Args) |
| 118 | + assert.Equal(t, "secret-123", mcpCfg.Env["API_KEY"]) |
| 119 | + assert.True(t, mcpCfg.Enabled) |
| 120 | +} |
| 121 | + |
| 122 | +const consistencyAPIKey = "t021-consistency-key" |
| 123 | + |
| 124 | +// newConsistencyServer builds an isolated *Server (own data dir + storage) with |
| 125 | +// a known API key so the REST surface can authenticate. The storage handle is |
| 126 | +// closed on cleanup so the temp-dir removal succeeds on Windows. |
| 127 | +func newConsistencyServer(t *testing.T) *Server { |
| 128 | + t.Helper() |
| 129 | + cfg := config.DefaultConfig() |
| 130 | + cfg.DataDir = t.TempDir() |
| 131 | + cfg.Listen = "127.0.0.1:0" |
| 132 | + cfg.APIKey = consistencyAPIKey |
| 133 | + srv, err := NewServer(cfg, zap.NewNop()) |
| 134 | + require.NoError(t, err) |
| 135 | + t.Cleanup(func() { _ = srv.Shutdown() }) |
| 136 | + return srv |
| 137 | +} |
| 138 | + |
| 139 | +// persistedServer reads the actually-persisted ServerConfig back from the |
| 140 | +// server's live config snapshot (not the function return value), so the |
| 141 | +// comparison reflects what reached storage. |
| 142 | +func persistedServer(t *testing.T, srv *Server, name string) *config.ServerConfig { |
| 143 | + t.Helper() |
| 144 | + cfg := srv.runtime.Config() |
| 145 | + require.NotNil(t, cfg, "runtime config must be available") |
| 146 | + for _, sc := range cfg.Servers { |
| 147 | + if sc != nil && sc.Name == name { |
| 148 | + return sc |
| 149 | + } |
| 150 | + } |
| 151 | + t.Fatalf("server %q not found in persisted config", name) |
| 152 | + return nil |
| 153 | +} |
| 154 | + |
| 155 | +// canonicalServerJSON serializes a ServerConfig with the per-add timestamps |
| 156 | +// zeroed, so byte-comparison reflects only the derived/persisted fields. |
| 157 | +func canonicalServerJSON(t *testing.T, sc *config.ServerConfig) string { |
| 158 | + t.Helper() |
| 159 | + clone := *sc |
| 160 | + clone.Created = time.Time{} |
| 161 | + clone.Updated = time.Time{} |
| 162 | + b, err := json.Marshal(&clone) |
| 163 | + require.NoError(t, err) |
| 164 | + return string(b) |
| 165 | +} |
0 commit comments