package mcpgateway import ( "context" "testing" "time" "go.mongodb.org/mongo-driver/v2/mongo" "github.com/enterpilot/gomodel/internal/storage/mongotest" "github.com/enterpilot/gomodel/internal/storage/sqlx" "github.com/enterpilot/gomodel/internal/storage/sqlx/sqlxtest" "github.com/stretchr/testify/require" ) // runStoreSuite exercises behaviour every Store implementation owes its // callers, against each backend available in this environment. func runStoreSuite(t *testing.T, body func(t *testing.T, store Store)) { t.Helper() sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) { store, err := NewSQLStore(context.Background(), db) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) body(t, store) }) mongotest.Run(t, func(t *testing.T, db *mongo.Database) { store, err := NewMongoDBStore(db) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) body(t, store) }) } func TestStoreRoundTrip(t *testing.T) { runStoreSuite(t, func(t *testing.T, store Store) { ctx := context.Background() server := ManagedServer{ Name: "github", DisplayName: "GitHub MCP", URL: "https://api.githubcopilot.com/mcp", Transport: "http", Headers: map[string]string{"Authorization": "Bearer secret"}, Description: "GitHub tools", Enabled: true, AllowedTools: []string{"create_issue"}, DisallowedTools: []string{"delete_repo"}, UserPaths: []string{"/team-a"}, DisallowedUserPaths: []string{"/team-a/contractors"}, ToolTimeoutSeconds: 45, } err := store.Upsert(ctx, server) require.NoError(t, err) got, err := store.Get(ctx, "github") require.NoError(t, err) require.Equal(t, server.URL, got.URL) require.Equal(t, "http", got.Transport) require.True(t, got.Enabled, "Get() = %+v, want round-tripped row", got) require.Equal(t, "github", got.Name) require.Equal(t, "GitHub MCP", got.DisplayName) require.Equal(t, "Bearer secret", got.Headers["Authorization"], "Get().Headers = %v, want secret preserved", got.Headers) require.Len(t, got.AllowedTools, 1) require.Equal(t, "create_issue", got.AllowedTools[0]) require.Equal(t, 45, got.ToolTimeoutSeconds) require.Equal(t, []string{"/team-a/contractors"}, got.DisallowedUserPaths) require.False(t, got.CreatedAt.IsZero()) require.False(t, got.UpdatedAt.IsZero(), "Get() timestamps not stamped: %+v", got) // Update preserves CreatedAt and bumps the row. server.Description = "updated" server.DisplayName = "GitHub 工具" server.Enabled = false err = store.Upsert(ctx, server) require.NoError(t, err) updated, err := store.Get(ctx, "github") require.NoError(t, err) require.Equal(t, "updated", updated.Description) require.False(t, updated.Enabled, "Get(updated) = %+v, want updated row", updated) require.Equal(t, "github", updated.Name) require.Equal(t, "GitHub 工具", updated.DisplayName) require.True(t, updated.CreatedAt.Equal(got.CreatedAt), "Get(updated).CreatedAt = %v, want original %v preserved", updated.CreatedAt, got.CreatedAt) list, err := store.List(ctx) require.NoError(t, err) require.Len(t, list, 1) err = store.Delete(ctx, "github") require.NoError(t, err) _, err = store.Get(ctx, "github") require.ErrorIs(t, err, ErrNotFound) err = store.Delete(ctx, "github") require.ErrorIs(t, err, ErrNotFound) }) } // TestSQLStoreMigratesDisplayName starts from the pre-display_name table shape // a long-lived deployment still has on disk. func TestSQLStoreMigratesDisplayName(t *testing.T) { sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) { ctx := context.Background() err := db.Schema(ctx, ` CREATE TABLE mcp_servers ( name TEXT PRIMARY KEY, url TEXT NOT NULL DEFAULT '', transport TEXT NOT NULL DEFAULT 'http', headers TEXT NOT NULL DEFAULT '{}', description TEXT NOT NULL DEFAULT '', enabled `+sqlx.TypeBool+` NOT NULL DEFAULT TRUE, allowed_tools TEXT NOT NULL DEFAULT '[]', disallowed_tools TEXT NOT NULL DEFAULT '[]', user_paths TEXT NOT NULL DEFAULT '[]', tool_timeout_seconds INTEGER NOT NULL DEFAULT 0, created_at `+sqlx.TypeInt64+` NOT NULL, updated_at `+sqlx.TypeInt64+` NOT NULL )`) require.NoError(t, err) _, err = db.Exec(ctx, `INSERT INTO mcp_servers (name, created_at, updated_at) VALUES (?, ?, ?)`, "linear", 1, 1) require.NoError(t, err) store, err := NewSQLStore(ctx, db) require.NoError(t, err) server, err := store.Get(ctx, "linear") require.NoError(t, err) require.Equal(t, "linear", server.DisplayName) require.Empty(t, server.DisallowedUserPaths) }) } func TestManagedServerValidateRejectsStdio(t *testing.T) { t.Parallel() server := ManagedServer{Name: "local", Transport: "stdio"} require.Error(t, server.Validate()) } func TestManagedServerValidateRequiresURL(t *testing.T) { t.Parallel() server := ManagedServer{Name: "web", Transport: "http"} require.Error(t, server.Validate()) server.URL = "ftp://nope" require.Error(t, server.Validate()) server.URL = "https://example.com/mcp" err := server.Validate() require.NoError(t, err) } func TestManagedServerValidateRejectsInvalidUserPath(t *testing.T) { t.Parallel() server := ManagedServer{ Name: "web", URL: "https://example.com/mcp", Transport: "http", UserPaths: []string{"/team/../admin"}, } require.Error(t, server.Validate()) } func TestManagedServerSpecDefaultsTimeout(t *testing.T) { t.Parallel() spec := ManagedServer{Name: "web", URL: "https://example.com/mcp", Transport: "http"}.Spec() require.Greater(t, spec.ToolTimeout, time.Duration(0)) require.False(t, spec.Managed) } func TestStoreUpdateRequiresExistingRow(t *testing.T) { runStoreSuite(t, func(t *testing.T, store Store) { ctx := context.Background() server := ManagedServer{Name: "coding", URL: "https://old.example.com/mcp", Transport: "http", Enabled: true} require.ErrorIs(t, store.Update(ctx, server), ErrNotFound, "update must not create a missing row") _, err := store.Get(ctx, "coding") require.ErrorIs(t, err, ErrNotFound) require.NoError(t, store.Upsert(ctx, server)) created, err := store.Get(ctx, "coding") require.NoError(t, err) server.URL = "https://new.example.com/mcp" server.UserPaths = []string{"/team"} require.NoError(t, store.Update(ctx, server)) got, err := store.Get(ctx, "coding") require.NoError(t, err) require.Equal(t, "https://new.example.com/mcp", got.URL) require.Equal(t, []string{"/team"}, got.UserPaths) require.Equal(t, created.CreatedAt.Unix(), got.CreatedAt.Unix(), "update keeps created_at") require.NoError(t, store.Delete(ctx, "coding")) require.ErrorIs(t, store.Update(ctx, server), ErrNotFound, "an edit after a delete must not recreate the row") }) } func TestVirtualStoreRoundTrip(t *testing.T) { runStoreSuite(t, func(t *testing.T, store Store) { ctx := context.Background() virtualStore, ok := store.(VirtualStore) require.True(t, ok, "every store backend persists virtual servers") virtual := ManagedVirtualServer{Name: "coding", Description: "code tools", Servers: []string{"github", "jira"}, ToolDiscovery: "search"} require.ErrorIs(t, virtualStore.UpdateVirtual(ctx, virtual), ErrNotFound, "update must not create a missing row") require.NoError(t, virtualStore.UpsertVirtual(ctx, virtual)) got, err := virtualStore.GetVirtual(ctx, "coding") require.NoError(t, err) require.Equal(t, "code tools", got.Description) require.Equal(t, []string{"github", "jira"}, got.Servers) require.Equal(t, "search", got.ToolDiscovery) require.False(t, got.CreatedAt.IsZero()) virtual.Servers = []string{"github"} virtual.ToolDiscovery = "" require.NoError(t, virtualStore.UpdateVirtual(ctx, virtual)) rows, err := virtualStore.ListVirtual(ctx) require.NoError(t, err) require.Len(t, rows, 1) require.Equal(t, []string{"github"}, rows[0].Servers) require.Empty(t, rows[0].ToolDiscovery) require.Equal(t, got.CreatedAt.Unix(), rows[0].CreatedAt.Unix(), "update keeps created_at") removed, err := virtualStore.DeleteVirtual(ctx, "coding") require.NoError(t, err) require.Equal(t, []string{"github"}, removed.Servers, "delete returns the row it removed") require.Equal(t, got.Generation, removed.Generation) _, err = virtualStore.DeleteVirtual(ctx, "coding") require.ErrorIs(t, err, ErrNotFound) _, err = virtualStore.GetVirtual(ctx, "coding") require.ErrorIs(t, err, ErrNotFound) }) } // Every creation of a virtual server row gets its own generation, which an // edit keeps, so a name deleted and recreated within one second (SQL stores // created_at in whole seconds) still tells its two incarnations apart. func TestVirtualStoreGenerationIdentifiesEachCreation(t *testing.T) { runStoreSuite(t, func(t *testing.T, store Store) { ctx := context.Background() virtualStore, ok := store.(VirtualStore) require.True(t, ok) created := time.Now().UTC().Truncate(time.Second) virtual := ManagedVirtualServer{Name: "coding", Servers: []string{"github"}, CreatedAt: created} require.NoError(t, virtualStore.UpsertVirtual(ctx, virtual)) first, err := virtualStore.GetVirtual(ctx, "coding") require.NoError(t, err) require.NotEmpty(t, first.Generation) virtual.Servers = []string{"github", "jira"} require.NoError(t, virtualStore.UpsertVirtual(ctx, virtual)) require.NoError(t, virtualStore.UpdateVirtual(ctx, virtual)) edited, err := virtualStore.GetVirtual(ctx, "coding") require.NoError(t, err) require.Equal(t, first.Generation, edited.Generation, "edits keep the generation") _, err = virtualStore.DeleteVirtual(ctx, "coding") require.NoError(t, err) require.NoError(t, virtualStore.UpsertVirtual(ctx, virtual)) recreated, err := virtualStore.GetVirtual(ctx, "coding") require.NoError(t, err) require.Equal(t, first.CreatedAt.Unix(), recreated.CreatedAt.Unix(), "created in the same second") require.NotEqual(t, first.Generation, recreated.Generation) require.NotEqual(t, first.Spec().Incarnation, recreated.Spec().Incarnation) }) } // A virtual server row from before generations gets the column with an // empty value, and is identified by its creation time. func TestSQLStoreMigratesVirtualGeneration(t *testing.T) { sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) { ctx := context.Background() err := db.Schema(ctx, ` CREATE TABLE mcp_virtual_servers ( name TEXT PRIMARY KEY, description TEXT NOT NULL DEFAULT '', servers TEXT NOT NULL DEFAULT '[]', tool_discovery TEXT NOT NULL DEFAULT '', created_at `+sqlx.TypeInt64+` NOT NULL, updated_at `+sqlx.TypeInt64+` NOT NULL )`) require.NoError(t, err) _, err = db.Exec(ctx, `INSERT INTO mcp_virtual_servers (name, servers, created_at, updated_at) VALUES (?, ?, ?, ?)`, "coding", `["github"]`, 1700000000, 1700000000) require.NoError(t, err) store, err := NewSQLStore(ctx, db) require.NoError(t, err) legacy, err := store.GetVirtual(ctx, "coding") require.NoError(t, err) require.Empty(t, legacy.Generation) require.NotEmpty(t, legacy.Spec().Incarnation) removed, err := store.DeleteVirtual(ctx, "coding") require.NoError(t, err) require.Equal(t, legacy.Spec().Incarnation, removed.Spec().Incarnation) require.NoError(t, store.UpsertVirtual(ctx, ManagedVirtualServer{Name: "coding", Servers: []string{"github"}, CreatedAt: legacy.CreatedAt})) recreated, err := store.GetVirtual(ctx, "coding") require.NoError(t, err) require.NotEqual(t, legacy.Spec().Incarnation, recreated.Spec().Incarnation) }) }